mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue