feat: support iOS LivePhoto backup (#950)

This commit is contained in:
Alex 2022-11-18 23:12:54 -06:00 committed by GitHub
parent 83e2cabbcc
commit 8bc64be77b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 678 additions and 243 deletions

View file

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

View file

@ -37,7 +37,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}
void handleSwipUpDown(PointerMoveEvent details) {
int sensitivity = 10;
int sensitivity = 15;
if (_zoomedIn) {
return;

View file

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

View file

@ -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: () {},
),
),
);
}

View file

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