feat: support iOS LivePhoto backup (#950)
This commit is contained in:
parent
83e2cabbcc
commit
8bc64be77b
30 changed files with 678 additions and 243 deletions
|
@ -22,27 +22,58 @@ class ImageViewerService {
|
|||
try {
|
||||
String fileName = p.basename(asset.originalPath);
|
||||
|
||||
var res = await _apiService.assetApi.downloadFileWithHttpInfo(
|
||||
asset.id,
|
||||
isThumb: false,
|
||||
isWeb: false,
|
||||
);
|
||||
// Download LivePhotos image and motion part
|
||||
if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) {
|
||||
var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
|
||||
asset.id,
|
||||
isThumb: false,
|
||||
isWeb: false,
|
||||
);
|
||||
|
||||
final AssetEntity? entity;
|
||||
var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
|
||||
asset.livePhotoVideoId!,
|
||||
isThumb: false,
|
||||
isWeb: false,
|
||||
);
|
||||
|
||||
if (asset.type == AssetTypeEnum.IMAGE) {
|
||||
entity = await PhotoManager.editor.saveImage(
|
||||
res.bodyBytes,
|
||||
final AssetEntity? entity;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
File videoFile = await File('${tempDir.path}/livephoto.mov').create();
|
||||
File imageFile = await File('${tempDir.path}/livephoto.heic').create();
|
||||
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
|
||||
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
|
||||
|
||||
entity = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||
imageFile: imageFile,
|
||||
videoFile: videoFile,
|
||||
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);
|
||||
}
|
||||
|
||||
return entity != null;
|
||||
return entity != null;
|
||||
} else {
|
||||
var res = await _apiService.assetApi.downloadFileWithHttpInfo(
|
||||
asset.id,
|
||||
isThumb: false,
|
||||
isWeb: false,
|
||||
);
|
||||
|
||||
final AssetEntity? entity;
|
||||
|
||||
if (asset.type == AssetTypeEnum.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);
|
||||
}
|
||||
return entity != null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error saving file $e");
|
||||
return false;
|
||||
|
|
|
@ -37,7 +37,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||
}
|
||||
|
||||
void handleSwipUpDown(PointerMoveEvent details) {
|
||||
int sensitivity = 10;
|
||||
int sensitivity = 15;
|
||||
|
||||
if (_zoomedIn) {
|
||||
return;
|
||||
|
|
|
@ -3,21 +3,23 @@ import 'package:flutter/material.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
const TopControlAppBar({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.onMoreInfoPressed,
|
||||
required this.onDownloadPressed,
|
||||
required this.onSharePressed,
|
||||
this.loading = false,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingMotionVideo,
|
||||
}) : super(key: key);
|
||||
|
||||
final Asset asset;
|
||||
final Function onMoreInfoPressed;
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final Function onSharePressed;
|
||||
final bool loading;
|
||||
final bool isPlayingMotionVideo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
@ -38,14 +40,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||
),
|
||||
),
|
||||
actions: [
|
||||
if (loading)
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 15.0),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
if (asset.remote?.livePhotoVideoId != null)
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
onToggleMotionVideo();
|
||||
},
|
||||
icon: isPlayingMotionVideo
|
||||
? const Icon(Icons.motion_photos_pause_outlined)
|
||||
: const Icon(Icons.play_circle_outline_rounded),
|
||||
),
|
||||
if (!asset.isLocal)
|
||||
IconButton(
|
||||
|
@ -79,7 +83,7 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
|||
Icons.more_horiz_rounded,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -33,10 +33,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final Box<dynamic> box = Hive.box(userInfoBox);
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final threeStageLoading = useState(false);
|
||||
final loading = useState(false);
|
||||
final isZoomed = useState<bool>(false);
|
||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
||||
|
||||
PageController controller =
|
||||
PageController(initialPage: assetList.indexOf(asset));
|
||||
|
@ -45,6 +45,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
() {
|
||||
threeStageLoading.value = appSettingService
|
||||
.getSetting<bool>(AppSettingsEnum.threeStageLoading);
|
||||
isPlayingMotionVideo.value = false;
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
|
@ -85,7 +86,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
loading: loading.value,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
||||
asset: assetList[indexOfAsset.value],
|
||||
onMoreInfoPressed: () {
|
||||
showInfo();
|
||||
|
@ -94,13 +95,18 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
? null
|
||||
: () {
|
||||
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
|
||||
assetList[indexOfAsset.value].remote!, context);
|
||||
assetList[indexOfAsset.value].remote!,
|
||||
context,
|
||||
);
|
||||
},
|
||||
onSharePressed: () {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.shareAsset(assetList[indexOfAsset.value], context);
|
||||
},
|
||||
onToggleMotionVideo: (() {
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: PageView.builder(
|
||||
|
@ -119,18 +125,28 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
getAssetExif();
|
||||
|
||||
if (assetList[index].isImage) {
|
||||
return ImageViewerPage(
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
threeStageLoading: threeStageLoading.value,
|
||||
);
|
||||
if (isPlayingMotionVideo.value) {
|
||||
return VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: true,
|
||||
onVideoEnded: () {
|
||||
isPlayingMotionVideo.value = false;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return ImageViewerPage(
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
threeStageLoading: threeStageLoading.value,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onVerticalDragUpdate: (details) {
|
||||
const int sensitivity = 10;
|
||||
const int sensitivity = 15;
|
||||
if (details.delta.dy > sensitivity) {
|
||||
// swipe down
|
||||
AutoRouter.of(context).pop();
|
||||
|
@ -141,7 +157,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
},
|
||||
child: Hero(
|
||||
tag: assetList[index].id,
|
||||
child: VideoViewerPage(asset: assetList[index]),
|
||||
child: VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: false,
|
||||
onVideoEnded: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,15 +15,26 @@ import 'package:video_player/video_player.dart';
|
|||
// ignore: must_be_immutable
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool isMotionVideo;
|
||||
final VoidCallback onVideoEnded;
|
||||
|
||||
const VideoViewerPage({Key? key, required this.asset}) : super(key: key);
|
||||
const VideoViewerPage({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.isMotionVideo,
|
||||
required this.onVideoEnded,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (asset.isLocal) {
|
||||
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
|
||||
return videoFile.when(
|
||||
data: (data) => VideoThumbnailPlayer(file: data),
|
||||
data: (data) => VideoThumbnailPlayer(
|
||||
file: data,
|
||||
isMotionVideo: false,
|
||||
onVideoEnded: () {},
|
||||
),
|
||||
error: (error, stackTrace) => Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
|
@ -41,14 +52,17 @@ class VideoViewerPage extends HookConsumerWidget {
|
|||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final box = Hive.box(userInfoBox);
|
||||
final String jwtToken = box.get(accessTokenKey);
|
||||
final String videoUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file/${asset.id}';
|
||||
final String videoUrl = isMotionVideo
|
||||
? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}'
|
||||
: '${box.get(serverEndpointKey)}/asset/file/${asset.id}';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
VideoThumbnailPlayer(
|
||||
url: videoUrl,
|
||||
jwtToken: jwtToken,
|
||||
isMotionVideo: isMotionVideo,
|
||||
onVideoEnded: onVideoEnded,
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
|
@ -72,9 +86,17 @@ class VideoThumbnailPlayer extends StatefulWidget {
|
|||
final String? url;
|
||||
final String? jwtToken;
|
||||
final File? file;
|
||||
final bool isMotionVideo;
|
||||
final VoidCallback onVideoEnded;
|
||||
|
||||
const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file})
|
||||
: super(key: key);
|
||||
const VideoThumbnailPlayer({
|
||||
Key? key,
|
||||
this.url,
|
||||
this.jwtToken,
|
||||
this.file,
|
||||
required this.onVideoEnded,
|
||||
required this.isMotionVideo,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
|
||||
|
@ -88,6 +110,13 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
initializePlayer();
|
||||
|
||||
videoPlayerController.addListener(() {
|
||||
if (videoPlayerController.value.position ==
|
||||
videoPlayerController.value.duration) {
|
||||
widget.onVideoEnded();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> initializePlayer() async {
|
||||
|
@ -115,7 +144,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
|||
autoPlay: true,
|
||||
autoInitialize: true,
|
||||
allowFullScreen: true,
|
||||
showControls: true,
|
||||
showControls: !widget.isMotionVideo,
|
||||
hideControlsTimer: const Duration(seconds: 5),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
@ -263,6 +264,13 @@ class BackupService {
|
|||
|
||||
req.files.add(assetRawUploadData);
|
||||
|
||||
if (entity.isLivePhoto) {
|
||||
var livePhotoRawUploadData = await _getLivePhotoFile(entity);
|
||||
if (livePhotoRawUploadData != null) {
|
||||
req.files.add(livePhotoRawUploadData);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
|
@ -322,6 +330,33 @@ class BackupService {
|
|||
return !anyErrors;
|
||||
}
|
||||
|
||||
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
|
||||
var motionFilePath = await entity.getMediaUrl();
|
||||
// var motionFilePath = '/var/mobile/Media/DCIM/103APPLE/IMG_3371.MOV'
|
||||
|
||||
if (motionFilePath != null) {
|
||||
var validPath = motionFilePath.replaceAll('file://', '');
|
||||
var motionFile = File(validPath);
|
||||
var fileStream = motionFile.openRead();
|
||||
String originalFileName = await entity.titleAsync;
|
||||
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
|
||||
var mimeType = FileHelper.getMimeType(validPath);
|
||||
|
||||
return http.MultipartFile(
|
||||
"livePhotoData",
|
||||
fileStream,
|
||||
motionFile.lengthSync(),
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
mimeType["type"],
|
||||
mimeType["subType"],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
switch (assetType) {
|
||||
case AssetType.audio:
|
||||
|
|
|
@ -65,7 +65,11 @@ class _$AppRouter extends RootStackRouter {
|
|||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: VideoViewerPage(key: args.key, asset: args.asset));
|
||||
child: VideoViewerPage(
|
||||
key: args.key,
|
||||
asset: args.asset,
|
||||
isMotionVideo: args.isMotionVideo,
|
||||
onVideoEnded: args.onVideoEnded));
|
||||
},
|
||||
BackupControllerRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
|
@ -340,24 +344,40 @@ class ImageViewerRouteArgs {
|
|||
/// generated route for
|
||||
/// [VideoViewerPage]
|
||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
VideoViewerRoute({Key? key, required Asset asset})
|
||||
VideoViewerRoute(
|
||||
{Key? key,
|
||||
required Asset asset,
|
||||
required bool isMotionVideo,
|
||||
required void Function() onVideoEnded})
|
||||
: super(VideoViewerRoute.name,
|
||||
path: '/video-viewer-page',
|
||||
args: VideoViewerRouteArgs(key: key, asset: asset));
|
||||
args: VideoViewerRouteArgs(
|
||||
key: key,
|
||||
asset: asset,
|
||||
isMotionVideo: isMotionVideo,
|
||||
onVideoEnded: onVideoEnded));
|
||||
|
||||
static const String name = 'VideoViewerRoute';
|
||||
}
|
||||
|
||||
class VideoViewerRouteArgs {
|
||||
const VideoViewerRouteArgs({this.key, required this.asset});
|
||||
const VideoViewerRouteArgs(
|
||||
{this.key,
|
||||
required this.asset,
|
||||
required this.isMotionVideo,
|
||||
required this.onVideoEnded});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Asset asset;
|
||||
|
||||
final bool isMotionVideo;
|
||||
|
||||
final void Function() onVideoEnded;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VideoViewerRouteArgs{key: $key, asset: $asset}';
|
||||
return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ Name | Type | Description | Notes
|
|||
**encodedVideoPath** | **String** | |
|
||||
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
|
||||
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
|
||||
**livePhotoVideoId** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
|
|
@ -43,48 +43,51 @@ class AlbumResponseDto {
|
|||
List<AssetResponseDto> assets;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.shared == shared &&
|
||||
other.sharedUsers == sharedUsers &&
|
||||
other.assets == assets;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.shared == shared &&
|
||||
other.sharedUsers == sharedUsers &&
|
||||
other.assets == assets;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(shared.hashCode) +
|
||||
(sharedUsers.hashCode) +
|
||||
(assets.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(shared.hashCode) +
|
||||
(sharedUsers.hashCode) +
|
||||
(assets.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
String toString() =>
|
||||
'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'assetCount'] = assetCount;
|
||||
_json[r'id'] = id;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'albumName'] = albumName;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'assetCount'] = assetCount;
|
||||
_json[r'id'] = id;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'albumName'] = albumName;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
if (albumThumbnailAssetId != null) {
|
||||
_json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
|
||||
} else {
|
||||
_json[r'albumThumbnailAssetId'] = null;
|
||||
}
|
||||
_json[r'shared'] = shared;
|
||||
_json[r'sharedUsers'] = sharedUsers;
|
||||
_json[r'assets'] = assets;
|
||||
_json[r'shared'] = shared;
|
||||
_json[r'sharedUsers'] = sharedUsers;
|
||||
_json[r'assets'] = assets;
|
||||
return _json;
|
||||
}
|
||||
|
||||
|
@ -98,13 +101,13 @@ class AlbumResponseDto {
|
|||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
|
||||
return AlbumResponseDto(
|
||||
assetCount: mapValueOfType<int>(json, r'assetCount')!,
|
||||
|
@ -112,7 +115,8 @@ class AlbumResponseDto {
|
|||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
albumName: mapValueOfType<String>(json, r'albumName')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
albumThumbnailAssetId:
|
||||
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
shared: mapValueOfType<bool>(json, r'shared')!,
|
||||
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
|
||||
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
|
||||
|
@ -121,7 +125,10 @@ class AlbumResponseDto {
|
|||
return null;
|
||||
}
|
||||
|
||||
static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<AlbumResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <AlbumResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
|
@ -149,12 +156,18 @@ class AlbumResponseDto {
|
|||
}
|
||||
|
||||
// maps a json object with a list of AlbumResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<AlbumResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<AlbumResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
final value = AlbumResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
|
@ -176,4 +189,3 @@ class AlbumResponseDto {
|
|||
'assets',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ class AssetResponseDto {
|
|||
required this.encodedVideoPath,
|
||||
this.exifInfo,
|
||||
this.smartInfo,
|
||||
required this.livePhotoVideoId,
|
||||
});
|
||||
|
||||
AssetTypeEnum type;
|
||||
|
@ -75,70 +76,77 @@ class AssetResponseDto {
|
|||
///
|
||||
SmartInfoResponseDto? smartInfo;
|
||||
|
||||
String? livePhotoVideoId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo &&
|
||||
other.livePhotoVideoId == livePhotoVideoId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
|
||||
String toString() =>
|
||||
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
if (resizePath != null) {
|
||||
_json[r'resizePath'] = resizePath;
|
||||
} else {
|
||||
_json[r'resizePath'] = null;
|
||||
}
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
if (mimeType != null) {
|
||||
_json[r'mimeType'] = mimeType;
|
||||
} else {
|
||||
_json[r'mimeType'] = null;
|
||||
}
|
||||
_json[r'duration'] = duration;
|
||||
_json[r'duration'] = duration;
|
||||
if (webpPath != null) {
|
||||
_json[r'webpPath'] = webpPath;
|
||||
} else {
|
||||
|
@ -159,6 +167,11 @@ class AssetResponseDto {
|
|||
} else {
|
||||
_json[r'smartInfo'] = null;
|
||||
}
|
||||
if (livePhotoVideoId != null) {
|
||||
_json[r'livePhotoVideoId'] = livePhotoVideoId;
|
||||
} else {
|
||||
_json[r'livePhotoVideoId'] = null;
|
||||
}
|
||||
return _json;
|
||||
}
|
||||
|
||||
|
@ -172,13 +185,13 @@ class AssetResponseDto {
|
|||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
|
||||
return AssetResponseDto(
|
||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||
|
@ -197,12 +210,16 @@ class AssetResponseDto {
|
|||
encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath'),
|
||||
exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
|
||||
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<AssetResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <AssetResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
|
@ -230,12 +247,18 @@ class AssetResponseDto {
|
|||
}
|
||||
|
||||
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<AssetResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
final value = AssetResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
|
@ -260,6 +283,6 @@ class AssetResponseDto {
|
|||
'duration',
|
||||
'webpPath',
|
||||
'encodedVideoPath',
|
||||
'livePhotoVideoId',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -734,7 +734,7 @@ packages:
|
|||
name: photo_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
version: "2.5.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -11,7 +11,7 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
photo_manager: ^2.2.1
|
||||
photo_manager: ^2.5.0
|
||||
flutter_hooks: ^0.18.0
|
||||
hooks_riverpod: ^2.0.0-dev.0
|
||||
hive: ^2.2.1
|
||||
|
@ -47,7 +47,6 @@ dependencies:
|
|||
# easy to remove packages:
|
||||
image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
@ -29,6 +29,7 @@ void main() {
|
|||
duration: '',
|
||||
webpPath: '',
|
||||
encodedVideoPath: '',
|
||||
livePhotoVideoId: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -21,7 +21,9 @@ export interface IAssetRepository {
|
|||
ownerId: string,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
isVisible: boolean,
|
||||
checksum?: Buffer,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity>;
|
||||
update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
||||
getAllByUserId(userId: string, skip?: number): Promise<AssetEntity[]>;
|
||||
|
@ -58,6 +60,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
.leftJoinAndSelect('asset.smartInfo', 'si')
|
||||
.where('asset.resizePath IS NOT NULL')
|
||||
.andWhere('si.id IS NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
@ -65,6 +68,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
return await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.where('asset.resizePath IS NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.orWhere('asset.resizePath = :resizePath', { resizePath: '' })
|
||||
.orWhere('asset.webpPath IS NULL')
|
||||
.orWhere('asset.webpPath = :webpPath', { webpPath: '' })
|
||||
|
@ -76,6 +80,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
.createQueryBuilder('asset')
|
||||
.leftJoinAndSelect('asset.exifInfo', 'ei')
|
||||
.where('ei."assetId" IS NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
@ -86,6 +91,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
.select(`COUNT(asset.id)`, 'count')
|
||||
.addSelect(`asset.type`, 'type')
|
||||
.where('"userId" = :userId', { userId: userId })
|
||||
.andWhere('asset.isVisible = true')
|
||||
.groupBy('asset.type')
|
||||
.getRawMany();
|
||||
|
||||
|
@ -120,6 +126,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
buckets: [...getAssetByTimeBucketDto.timeBucket],
|
||||
})
|
||||
.andWhere('asset.resizePath is not NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.orderBy('asset.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
@ -134,6 +141,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
.addSelect(`date_trunc('month', "createdAt")`, 'timeBucket')
|
||||
.where('"userId" = :userId', { userId: userId })
|
||||
.andWhere('asset.resizePath is not NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.groupBy(`date_trunc('month', "createdAt")`)
|
||||
.orderBy(`date_trunc('month', "createdAt")`, 'DESC')
|
||||
.getRawMany();
|
||||
|
@ -144,6 +152,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
.addSelect(`date_trunc('day', "createdAt")`, 'timeBucket')
|
||||
.where('"userId" = :userId', { userId: userId })
|
||||
.andWhere('asset.resizePath is not NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.groupBy(`date_trunc('day', "createdAt")`)
|
||||
.orderBy(`date_trunc('day', "createdAt")`, 'DESC')
|
||||
.getRawMany();
|
||||
|
@ -156,6 +165,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
return await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.where('asset.userId = :userId', { userId: userId })
|
||||
.andWhere('asset.isVisible = true')
|
||||
.leftJoin('asset.exifInfo', 'ei')
|
||||
.leftJoin('asset.smartInfo', 'si')
|
||||
.select('si.tags', 'tags')
|
||||
|
@ -179,6 +189,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
FROM assets a
|
||||
LEFT JOIN smart_info si ON a.id = si."assetId"
|
||||
WHERE a."userId" = $1
|
||||
AND a."isVisible" = true
|
||||
AND si.objects IS NOT NULL
|
||||
`,
|
||||
[userId],
|
||||
|
@ -192,6 +203,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
FROM assets a
|
||||
LEFT JOIN exif e ON a.id = e."assetId"
|
||||
WHERE a."userId" = $1
|
||||
AND a."isVisible" = true
|
||||
AND e.city IS NOT NULL
|
||||
AND a.type = 'IMAGE';
|
||||
`,
|
||||
|
@ -222,6 +234,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
.createQueryBuilder('asset')
|
||||
.where('asset.userId = :userId', { userId: userId })
|
||||
.andWhere('asset.resizePath is not NULL')
|
||||
.andWhere('asset.isVisible = true')
|
||||
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
|
||||
.skip(skip || 0)
|
||||
.orderBy('asset.createdAt', 'DESC');
|
||||
|
@ -242,13 +255,15 @@ export class AssetRepository implements IAssetRepository {
|
|||
ownerId: string,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
isVisible: boolean,
|
||||
checksum?: Buffer,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = new AssetEntity();
|
||||
asset.deviceAssetId = createAssetDto.deviceAssetId;
|
||||
asset.userId = ownerId;
|
||||
asset.deviceId = createAssetDto.deviceId;
|
||||
asset.type = createAssetDto.assetType || AssetType.OTHER;
|
||||
asset.type = !isVisible ? AssetType.VIDEO : createAssetDto.assetType || AssetType.OTHER; // If an asset is not visible, it is a LivePhotos video portion, therefore we can confidently assign the type as VIDEO here
|
||||
asset.originalPath = originalPath;
|
||||
asset.createdAt = createAssetDto.createdAt;
|
||||
asset.modifiedAt = createAssetDto.modifiedAt;
|
||||
|
@ -256,6 +271,8 @@ export class AssetRepository implements IAssetRepository {
|
|||
asset.mimeType = mimeType;
|
||||
asset.duration = createAssetDto.duration || null;
|
||||
asset.checksum = checksum || null;
|
||||
asset.isVisible = isVisible;
|
||||
asset.livePhotoVideoId = livePhotoAssetEntity ? livePhotoAssetEntity.id : null;
|
||||
|
||||
const createdAsset = await this.assetRepository.save(asset);
|
||||
|
||||
|
@ -286,6 +303,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
where: {
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
isVisible: true,
|
||||
},
|
||||
select: ['deviceAssetId'],
|
||||
});
|
||||
|
|
|
@ -10,16 +10,14 @@ import {
|
|||
Response,
|
||||
Headers,
|
||||
Delete,
|
||||
Logger,
|
||||
HttpCode,
|
||||
BadRequestException,
|
||||
UploadedFile,
|
||||
Header,
|
||||
Put,
|
||||
UploadedFiles,
|
||||
} from '@nestjs/common';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
|
@ -27,12 +25,6 @@ import { Response as Res } from 'express';
|
|||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import { CommunicationGateway } from '../communication/communication.gateway';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
import { IAssetUploadedJob } from '@app/job/index';
|
||||
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
||||
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
|
@ -47,7 +39,6 @@ import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
|
|||
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||
|
@ -64,17 +55,18 @@ import {
|
|||
@ApiTags('Asset')
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(
|
||||
private wsCommunicateionGateway: CommunicationGateway,
|
||||
private assetService: AssetService,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
|
||||
@InjectQueue(QueueNameEnum.ASSET_UPLOADED)
|
||||
private assetUploadedQueue: Queue<IAssetUploadedJob>,
|
||||
) {}
|
||||
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('assetData', assetUploadOption))
|
||||
@UseInterceptors(
|
||||
FileFieldsInterceptor(
|
||||
[
|
||||
{ name: 'assetData', maxCount: 1 },
|
||||
{ name: 'livePhotoData', maxCount: 1 },
|
||||
],
|
||||
assetUploadOption,
|
||||
),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
description: 'Asset Upload Information',
|
||||
|
@ -82,53 +74,14 @@ export class AssetController {
|
|||
})
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body(ValidationPipe) assetInfo: CreateAssetDto,
|
||||
@UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] },
|
||||
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
const checksum = await this.assetService.calculateChecksum(file.path);
|
||||
const originalAssetData = files.assetData[0];
|
||||
const livePhotoAssetData = files.livePhotoData?.[0];
|
||||
|
||||
try {
|
||||
const savedAsset = await this.assetService.createUserAsset(
|
||||
authUser,
|
||||
assetInfo,
|
||||
file.path,
|
||||
file.mimetype,
|
||||
checksum,
|
||||
);
|
||||
|
||||
if (!savedAsset) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: file.path,
|
||||
} as any,
|
||||
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
await this.assetUploadedQueue.add(
|
||||
assetUploadedProcessorName,
|
||||
{ asset: savedAsset, fileName: file.originalname },
|
||||
{ jobId: savedAsset.id },
|
||||
);
|
||||
|
||||
return new AssetFileUploadResponseDto(savedAsset.id);
|
||||
} catch (err) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: file.path,
|
||||
} as any,
|
||||
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
||||
|
||||
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
||||
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
|
||||
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
|
||||
return new AssetFileUploadResponseDto(existedAsset.id);
|
||||
}
|
||||
|
||||
Logger.error(`Error uploading file ${err}`);
|
||||
throw new BadRequestException(`Error uploading file`, `${err}`);
|
||||
}
|
||||
return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
|
||||
}
|
||||
|
||||
@Get('/download/:assetId')
|
||||
|
@ -270,6 +223,14 @@ export class AssetController {
|
|||
continue;
|
||||
}
|
||||
deleteAssetList.push(assets);
|
||||
|
||||
if (assets.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
|
||||
if (livePhotoVideo) {
|
||||
deleteAssetList.push(livePhotoVideo);
|
||||
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.assetService.deleteAssetById(authUser, assetIds);
|
||||
|
|
|
@ -25,6 +25,14 @@ import { DownloadModule } from '../../modules/download/download.module';
|
|||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueNameEnum.VIDEO_CONVERSION,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [
|
||||
|
|
|
@ -8,13 +8,18 @@ import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group
|
|||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
|
||||
import { Queue } from 'bull';
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sui: AssetService;
|
||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
|
||||
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
|
||||
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
|
||||
let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: 'user_id_1',
|
||||
email: 'auth@test.com',
|
||||
|
@ -123,7 +128,14 @@ describe('AssetService', () => {
|
|||
downloadArchive: jest.fn(),
|
||||
};
|
||||
|
||||
sui = new AssetService(assetRepositoryMock, a, downloadServiceMock as DownloadService);
|
||||
sui = new AssetService(
|
||||
assetRepositoryMock,
|
||||
a,
|
||||
backgroundTaskServiceMock,
|
||||
assetUploadedQueueMock,
|
||||
videoConversionQueueMock,
|
||||
downloadServiceMock as DownloadService,
|
||||
);
|
||||
});
|
||||
|
||||
// Currently failing due to calculate checksum from a file
|
||||
|
@ -141,6 +153,7 @@ describe('AssetService', () => {
|
|||
originalPath,
|
||||
mimeType,
|
||||
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.userId).toEqual(authUser.id);
|
||||
|
|
|
@ -10,8 +10,8 @@ import {
|
|||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import { constants, createReadStream, ReadStream, stat } from 'fs';
|
||||
|
@ -41,6 +41,17 @@ import { timeUtils } from '@app/common/utils';
|
|||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import {
|
||||
assetUploadedProcessorName,
|
||||
IAssetUploadedJob,
|
||||
IVideoTranscodeJob,
|
||||
mp4ConversionProcessorName,
|
||||
QueueNameEnum,
|
||||
} from '@app/job';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { DownloadDto } from './dto/download-library.dto';
|
||||
|
||||
|
@ -55,15 +66,116 @@ export class AssetService {
|
|||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
|
||||
@InjectQueue(QueueNameEnum.ASSET_UPLOADED)
|
||||
private assetUploadedQueue: Queue<IAssetUploadedJob>,
|
||||
|
||||
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
|
||||
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||
|
||||
private downloadService: DownloadService,
|
||||
) {}
|
||||
|
||||
public async handleUploadedAsset(
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
res: Res,
|
||||
originalAssetData: Express.Multer.File,
|
||||
livePhotoAssetData?: Express.Multer.File,
|
||||
) {
|
||||
const checksum = await this.calculateChecksum(originalAssetData.path);
|
||||
const isLivePhoto = livePhotoAssetData !== undefined;
|
||||
let livePhotoAssetEntity: AssetEntity | undefined;
|
||||
|
||||
try {
|
||||
if (isLivePhoto) {
|
||||
const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path);
|
||||
livePhotoAssetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
livePhotoAssetData.path,
|
||||
livePhotoAssetData.mimetype,
|
||||
livePhotoChecksum,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!livePhotoAssetEntity) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: livePhotoAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
await this.videoConversionQueue.add(
|
||||
mp4ConversionProcessorName,
|
||||
{ asset: livePhotoAssetEntity },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
|
||||
const assetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
originalAssetData.path,
|
||||
originalAssetData.mimetype,
|
||||
checksum,
|
||||
true,
|
||||
livePhotoAssetEntity,
|
||||
);
|
||||
|
||||
if (!assetEntity) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: originalAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
await this.assetUploadedQueue.add(
|
||||
assetUploadedProcessorName,
|
||||
{ asset: assetEntity, fileName: originalAssetData.originalname },
|
||||
{ jobId: assetEntity.id },
|
||||
);
|
||||
|
||||
return new AssetFileUploadResponseDto(assetEntity.id);
|
||||
} catch (err) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: originalAssetData.path,
|
||||
} as any,
|
||||
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
||||
|
||||
if (isLivePhoto) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: livePhotoAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
}
|
||||
|
||||
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
||||
const existedAsset = await this.getAssetByChecksum(authUser.id, checksum);
|
||||
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
|
||||
return new AssetFileUploadResponseDto(existedAsset.id);
|
||||
}
|
||||
|
||||
Logger.error(`Error uploading file ${err}`);
|
||||
throw new BadRequestException(`Error uploading file`, `${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async createUserAsset(
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
checksum: Buffer,
|
||||
isVisible: boolean,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity> {
|
||||
// Check valid time.
|
||||
const createdAt = createAssetDto.createdAt;
|
||||
|
@ -82,7 +194,9 @@ export class AssetService {
|
|||
authUser.id,
|
||||
originalPath,
|
||||
mimeType,
|
||||
isVisible,
|
||||
checksum,
|
||||
livePhotoAssetEntity,
|
||||
);
|
||||
|
||||
return assetEntity;
|
||||
|
|
|
@ -22,6 +22,7 @@ export class AssetResponseDto {
|
|||
encodedVideoPath!: string | null;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
livePhotoVideoId!: string | null;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
|
@ -42,5 +43,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
|||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -54,7 +54,12 @@ function filename(req: Request, file: Express.Multer.File, cb: any) {
|
|||
}
|
||||
|
||||
const fileNameUUID = randomUUID();
|
||||
|
||||
if (file.fieldname === 'livePhotoData') {
|
||||
const livePhotoFileName = `${fileNameUUID}.mov`;
|
||||
return cb(null, sanitize(livePhotoFileName));
|
||||
}
|
||||
|
||||
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
|
||||
const sanitizedFileName = sanitize(fileName);
|
||||
cb(null, sanitizedFileName);
|
||||
return cb(null, sanitize(fileName));
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ export class VideoTranscodeProcessor {
|
|||
private immichConfigService: ImmichConfigService,
|
||||
) {}
|
||||
|
||||
@Process({ name: mp4ConversionProcessorName, concurrency: 1 })
|
||||
@Process({ name: mp4ConversionProcessorName, concurrency: 2 })
|
||||
async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
|
||||
const { asset } = job.data;
|
||||
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -51,6 +51,12 @@ export class AssetEntity {
|
|||
@Column({ type: 'varchar', nullable: true })
|
||||
duration!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isVisible!: boolean;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
livePhotoVideoId!: string | null;
|
||||
|
||||
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
||||
exifInfo?: ExifEntity;
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddLivePhotosRelatedColumnToAssetTable1668383120461 implements MigrationInterface {
|
||||
name = 'AddLivePhotosRelatedColumnToAssetTable1668383120461'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" ADD "isVisible" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`ALTER TABLE "assets" ADD "livePhotoVideoId" uuid`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "livePhotoVideoId"`);
|
||||
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isVisible"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -440,6 +440,12 @@ export interface AssetResponseDto {
|
|||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'smartInfo'?: SmartInfoResponseDto;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'livePhotoVideoId': string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
|
1
web/src/app.d.ts
vendored
1
web/src/app.d.ts
vendored
|
@ -13,6 +13,7 @@ declare namespace App {
|
|||
// Source: https://stackoverflow.com/questions/63814432/typescript-typing-of-non-standard-window-event-in-svelte
|
||||
// To fix the <svelte:window... in components/asset-viewer/photo-viewer.svelte
|
||||
declare namespace svelte.JSX {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface HTMLAttributes<T> {
|
||||
oncopyImage?: () => void;
|
||||
}
|
||||
|
|
|
@ -12,12 +12,16 @@
|
|||
import Star from 'svelte-material-icons/Star.svelte';
|
||||
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
|
||||
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
|
||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { AssetResponseDto } from '../../../api';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let showCopyButton: boolean;
|
||||
export let showMotionPlayButton: boolean;
|
||||
export let isMotionPhotoPlaying = false;
|
||||
|
||||
const isOwner = asset.ownerId === $page.data.user.id;
|
||||
|
||||
|
@ -48,17 +52,41 @@
|
|||
<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
||||
</div>
|
||||
<div class="text-white flex gap-2">
|
||||
{#if showMotionPlayButton}
|
||||
{#if isMotionPhotoPlaying}
|
||||
<CircleIconButton
|
||||
logo={MotionPauseOutline}
|
||||
title="Stop Motion Photo"
|
||||
on:click={() => dispatch('stopMotionPhoto')}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
logo={MotionPlayOutline}
|
||||
title="Play Motion Photo"
|
||||
on:click={() => dispatch('playMotionPhoto')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showCopyButton}
|
||||
<CircleIconButton
|
||||
logo={ContentCopy}
|
||||
title="Copy Image"
|
||||
on:click={() => {
|
||||
const copyEvent = new CustomEvent('copyImage');
|
||||
window.dispatchEvent(copyEvent);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
|
||||
<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
|
||||
<CircleIconButton
|
||||
logo={CloudDownloadOutline}
|
||||
on:click={() => dispatch('download')}
|
||||
title="Download"
|
||||
/>
|
||||
<CircleIconButton
|
||||
logo={InformationOutline}
|
||||
on:click={() => dispatch('showDetail')}
|
||||
title="Info"
|
||||
/>
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
logo={asset.isFavorite ? Star : StarOutline}
|
||||
|
@ -66,8 +94,12 @@
|
|||
title="Favorite"
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} />
|
||||
<CircleIconButton logo={DotsVertical} on:click={(event) => showOptionsMenu(event)} />
|
||||
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
|
||||
<CircleIconButton
|
||||
logo={DotsVertical}
|
||||
on:click={(event) => showOptionsMenu(event)}
|
||||
title="More"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
let isShowAlbumPicker = false;
|
||||
let addToSharedAlbum = true;
|
||||
|
||||
let shouldPlayMotionPhoto = false;
|
||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
|
||||
|
||||
onMount(() => {
|
||||
|
@ -88,10 +88,20 @@
|
|||
isShowDetail = !isShowDetail;
|
||||
};
|
||||
|
||||
const downloadFile = async () => {
|
||||
const handleDownload = () => {
|
||||
if (asset.livePhotoVideoId) {
|
||||
downloadFile(asset.livePhotoVideoId, true);
|
||||
downloadFile(asset.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(asset.id, false);
|
||||
};
|
||||
|
||||
const downloadFile = async (assetId: string, isLivePhoto: boolean) => {
|
||||
try {
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageExtension = isLivePhoto ? 'mov' : asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
|
||||
// If assets is already download -> return;
|
||||
|
@ -101,7 +111,7 @@
|
|||
|
||||
$downloadAssets[imageFileName] = 0;
|
||||
|
||||
const { data, status } = await api.assetApi.downloadFile(asset.id, false, false, {
|
||||
const { data, status } = await api.assetApi.downloadFile(assetId, false, false, {
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
|
@ -221,14 +231,18 @@
|
|||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||
showCopyButton={asset.type === AssetTypeEnum.Image}
|
||||
showMotionPlayButton={!!asset.livePhotoVideoId}
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={downloadFile}
|
||||
showCopyButton={asset.type === AssetTypeEnum.Image}
|
||||
on:download={handleDownload}
|
||||
on:delete={deleteAsset}
|
||||
on:favorite={toggleFavorite}
|
||||
on:addToAlbum={() => openAlbumPicker(false)}
|
||||
on:addToSharedAlbum={() => openAlbumPicker(true)}
|
||||
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
|
||||
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -257,7 +271,15 @@
|
|||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<PhotoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
|
||||
<VideoViewer
|
||||
assetId={asset.livePhotoVideoId}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
{:else}
|
||||
<PhotoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
{:else}
|
||||
<VideoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto, getFileUrl } from '@api';
|
||||
|
||||
|
@ -12,6 +12,7 @@
|
|||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isVideoLoading = true;
|
||||
let videoUrl: string;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(async () => {
|
||||
const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
|
||||
|
@ -49,6 +50,7 @@
|
|||
controls
|
||||
class="h-full object-contain"
|
||||
on:canplay={handleCanPlay}
|
||||
on:ended={() => dispatch('onVideoEnded')}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
|
||||
|
||||
|
@ -19,6 +21,7 @@
|
|||
let imageData: string;
|
||||
|
||||
let mouseOver = false;
|
||||
let playMotionVideo = false;
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
let mouseOverIcon = false;
|
||||
|
@ -28,10 +31,15 @@
|
|||
let videoProgress = '00:00';
|
||||
let videoUrl: string;
|
||||
|
||||
const loadVideoData = async () => {
|
||||
const loadVideoData = async (isLivePhoto: boolean) => {
|
||||
isThumbnailVideoPlaying = false;
|
||||
|
||||
videoUrl = getFileUrl(asset.id, false, true);
|
||||
if (isLivePhoto && asset.livePhotoVideoId) {
|
||||
console.log('get file url');
|
||||
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
|
||||
} else {
|
||||
videoUrl = getFileUrl(asset.id, false, true);
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoDurationInString = (currentTime: number) => {
|
||||
|
@ -202,6 +210,32 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||
<div
|
||||
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
|
||||
>
|
||||
<span
|
||||
in:fade={{ duration: 500 }}
|
||||
on:mouseenter={() => {
|
||||
playMotionVideo = true;
|
||||
loadVideoData(true);
|
||||
}}
|
||||
on:mouseleave={() => (playMotionVideo = false)}
|
||||
>
|
||||
{#if playMotionVideo}
|
||||
<span in:fade={{ duration: 500 }}>
|
||||
<MotionPauseOutline size="24" />
|
||||
</span>
|
||||
{:else}
|
||||
<span in:fade={{ duration: 500 }}>
|
||||
<MotionPlayOutline size="24" />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<!-- {/if} -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Thumbnail -->
|
||||
{#if intersecting}
|
||||
<img
|
||||
|
@ -217,7 +251,27 @@
|
|||
{/if}
|
||||
|
||||
{#if mouseOver && asset.type === AssetTypeEnum.Video}
|
||||
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
|
||||
<div class="absolute w-full h-full top-0" on:mouseenter={() => loadVideoData(false)}>
|
||||
{#if videoUrl}
|
||||
<video
|
||||
muted
|
||||
autoplay
|
||||
preload="none"
|
||||
class="h-full object-cover"
|
||||
width="250px"
|
||||
style:width={`${thumbnailSize}px`}
|
||||
on:canplay={handleCanPlay}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if playMotionVideo && asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||
<div class="absolute w-full h-full top-0">
|
||||
{#if videoUrl}
|
||||
<video
|
||||
muted
|
||||
|
|
Loading…
Reference in a new issue