From 520ab6b30a2596a92aec93c0ed3f69d565cfb622 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Aug 2024 14:20:45 -0500 Subject: [PATCH 01/54] add native player library --- mobile/ios/Podfile.lock | 6 ++ .../lib/pages/common/video_viewer.page.dart | 26 ++++--- .../widgets/asset_viewer/video_player.dart | 67 ++++++++++++++----- mobile/pubspec.lock | 8 +++ mobile/pubspec.yaml | 1 + 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 55d8e0fa00ac4..bd6da47db9051 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -65,6 +65,8 @@ PODS: - maplibre_gl (0.0.1): - Flutter - MapLibre (= 5.14.0-pre3) + - native_video_player (1.0.0): + - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -115,6 +117,7 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) + - native_video_player (from `.symlinks/plugins/native_video_player/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) @@ -168,6 +171,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/isar_flutter_libs/ios" maplibre_gl: :path: ".symlinks/plugins/maplibre_gl/ios" + native_video_player: + :path: ".symlinks/plugins/native_video_player/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -210,6 +215,7 @@ SPEC CHECKSUMS: isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097 MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 + native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index 774d4eb31ec6e..082bb1eb67502 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -104,7 +104,7 @@ class VideoViewerPage extends HookConsumerWidget { // Done in a microtask to avoid setting the state while the is building if (!isMotionVideo) { Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; + ref.read(showControlsProvider.notifier).show = true; }); } @@ -147,16 +147,20 @@ class VideoViewerPage extends HookConsumerWidget { ), if (controller != null) SizedBox( - height: context.height, - width: context.width, - child: VideoPlayerViewer( - controller: controller, - isMotionVideo: isMotionVideo, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - loopVideo: loopVideo, + height: 16 / 9 * size.width, + width: size.width, + child: AspectRatio( + aspectRatio: 16 / 9, + child: VideoPlayerViewer( + controller: controller, + isMotionVideo: isMotionVideo, + placeholder: placeholder, + hideControlsTimer: hideControlsTimer, + showControls: showControls, + showDownloadingIndicator: showDownloadingIndicator, + loopVideo: loopVideo, + asset: asset, + ), ), ), ], diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart index ebf158b59a5fb..3e8a3c623c837 100644 --- a/mobile/lib/widgets/asset_viewer/video_player.dart +++ b/mobile/lib/widgets/asset_viewer/video_player.dart @@ -1,8 +1,11 @@ import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; +import 'package:native_video_player/native_video_player.dart'; import 'package:video_player/video_player.dart'; class VideoPlayerViewer extends HookConsumerWidget { @@ -13,6 +16,7 @@ class VideoPlayerViewer extends HookConsumerWidget { final bool showControls; final bool showDownloadingIndicator; final bool loopVideo; + final Asset asset; const VideoPlayerViewer({ super.key, @@ -23,26 +27,59 @@ class VideoPlayerViewer extends HookConsumerWidget { required this.showControls, required this.showDownloadingIndicator, required this.loopVideo, + required this.asset, }); @override Widget build(BuildContext context, WidgetRef ref) { - final chewie = useChewieController( - controller: controller, - controlsSafeAreaMinimum: const EdgeInsets.only( - bottom: 100, - ), - placeholder: SizedBox.expand(child: placeholder), - customControls: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, - ), - showControls: showControls && !isMotionVideo, - hideControlsTimer: hideControlsTimer, - loopVideo: loopVideo, - ); + // final chewie = useChewieController( + // controller: controller, + // controlsSafeAreaMinimum: const EdgeInsets.only( + // bottom: 100, + // ), + // placeholder: SizedBox.expand(child: placeholder), + // customControls: CustomVideoPlayerControls( + // hideTimerDuration: hideControlsTimer, + // ), + // showControls: showControls && !isMotionVideo, + // hideControlsTimer: hideControlsTimer, + // loopVideo: loopVideo, + // ); + + // return Chewie( + // controller: chewie, + // ); + + return NativeVideoPlayerView( + onViewReady: (controller) async { + try { + String path = ''; + VideoSourceType type = VideoSourceType.file; + if (asset.isLocal && asset.livePhotoVideoId == null) { + // Use a local file for the video player controller + final file = await asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + path = file.path; + type = VideoSourceType.file; + + final videoSource = await VideoSource.init( + path: path, + type: type, + ); + + await controller.loadVideoSource(videoSource); + await controller.play(); - return Chewie( - controller: chewie, + Future.delayed(const Duration(milliseconds: 100), () async { + await controller.setVolume(0.5); + }); + } + } catch (e) { + print('Error loading video: $e'); + } + }, ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9dc53e42b97b4..6b4c2703e46e2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1024,6 +1024,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + native_video_player: + dependency: "direct main" + description: + name: native_video_player + sha256: "8df92df138c13ebf9df6b30525f9c4198534705fd450a98da14856d3a0e48cd4" + url: "https://pub.dev" + source: hosted + version: "1.3.1" nested: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 235c58ce63e84..ab59f516cd707 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 + native_video_player: ^1.3.1 #image editing packages crop_image: ^1.0.13 From 28f1ca1f11a47e67567d378f1a9fa358b8f49af9 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Aug 2024 17:30:30 -0500 Subject: [PATCH 02/54] splitup the player --- .../lib/pages/common/gallery_viewer.page.dart | 44 ++++--- .../common/native_video_viewer.page.dart | 117 ++++++++++++++++++ .../lib/pages/common/video_viewer.page.dart | 24 ++-- ...tive_video_player_controller_provider.dart | 5 + .../asset_viewer/native_video_player.dart | 63 ++++++++++ .../widgets/asset_viewer/video_player.dart | 67 +++------- 6 files changed, 240 insertions(+), 80 deletions(-) create mode 100644 mobile/lib/pages/common/native_video_viewer.page.dart create mode 100644 mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart create mode 100644 mobile/lib/widgets/asset_viewer/native_video_player.dart diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 5747332587a91..cf3ae5f687803 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -62,7 +63,6 @@ class GalleryViewerPage extends HookConsumerWidget { final localPosition = useState(null); final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); - // Update is playing motion video ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { isPlayingVideo.value = state == VideoPlaybackState.playing; @@ -353,6 +353,9 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } else { + final useNativePlayer = + asset.isLocal && asset.livePhotoVideoId == null; + return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, @@ -367,19 +370,32 @@ class GalleryViewerPage extends HookConsumerWidget { maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, - child: VideoViewerPage( - key: ValueKey(a), - asset: a, - isMotionVideo: a.livePhotoVideoId != null, - loopVideo: shouldLoopVideo.value, - placeholder: Image( - image: provider, - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), - ), + child: useNativePlayer + ? NativeVideoViewerPage( + key: ValueKey(a), + asset: a, + // loopVideo: shouldLoopVideo.value, + // placeholder: Image( + // image: provider, + // fit: BoxFit.contain, + // height: context.height, + // width: context.width, + // alignment: Alignment.center, + // ), + ) + : VideoViewerPage( + key: ValueKey(a), + asset: a, + isMotionVideo: a.livePhotoVideoId != null, + loopVideo: shouldLoopVideo.value, + placeholder: Image( + image: provider, + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), ); } }, diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart new file mode 100644 index 0000000000000..0a224089abfa9 --- /dev/null +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/providers/asset_viewer/native_video_player_controller_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/video_player.dart'; +import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +class NativeVideoViewerPage extends ConsumerStatefulWidget { + final Asset asset; + final Widget? placeholder; + + const NativeVideoViewerPage({ + super.key, + required this.asset, + this.placeholder, + }); + + @override + NativeVideoViewerPageState createState() => NativeVideoViewerPageState(); +} + +class NativeVideoViewerPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + double videoWidth = size.width; + double videoHeight = size.height; + + NativeVideoPlayerController? controller; + + void initController(NativeVideoPlayerController videoCtrl) { + controller = videoCtrl; + + controller?.onPlaybackReady.addListener(() { + // Emitted when the video loaded successfully and it's ready to play. + // At this point, videoInfo is available. + final videoInfo = controller?.videoInfo; + + setState(() { + if (videoInfo != null) { + videoWidth = videoInfo.width.toDouble(); + videoHeight = videoInfo.height.toDouble(); + + print(videoHeight); + print(videoWidth); + } + }); + + final videoDuration = videoInfo?.duration; + + controller?.play(); + }); + + controller?.onPlaybackStatusChanged.addListener(() { + final playbackStatus = controller?.playbackInfo?.status; + // playbackStatus can be playing, paused, or stopped. + }); + + controller?.onPlaybackPositionChanged.addListener(() { + final playbackPosition = controller?.playbackInfo?.position; + }); + + controller?.onPlaybackEnded.addListener(() { + // Emitted when the video has finished playing. + }); + } + + dispose() { + controller = null; + super.dispose(); + } + + return PopScope( + onPopInvoked: (pop) { + ref.read(videoPlaybackValueProvider.notifier).value = + VideoPlaybackValue.uninitialized(); + }, + child: SizedBox( + height: videoHeight, + width: videoWidth, + child: AspectRatio( + aspectRatio: 16 / 9, + child: NativeVideoPlayerView( + onViewReady: (c) async { + // Use a local file for the video player controller + final file = await widget.asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + + final videoSource = await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + + await c.loadVideoSource(videoSource); + initController(c); + }, + ), + ), + ), + ); + } + // final Asset asset; + // final Widget? placeholder; + // final Duration hideControlsTimer; + // final bool showControls; + // final bool showDownloadingIndicator; + // final bool loopVideo; +} diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index 082bb1eb67502..d605d894362ee 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -104,7 +104,7 @@ class VideoViewerPage extends HookConsumerWidget { // Done in a microtask to avoid setting the state while the is building if (!isMotionVideo) { Future.microtask(() { - ref.read(showControlsProvider.notifier).show = true; + ref.read(showControlsProvider.notifier).show = false; }); } @@ -147,20 +147,16 @@ class VideoViewerPage extends HookConsumerWidget { ), if (controller != null) SizedBox( - height: 16 / 9 * size.width, + height: size.height, width: size.width, - child: AspectRatio( - aspectRatio: 16 / 9, - child: VideoPlayerViewer( - controller: controller, - isMotionVideo: isMotionVideo, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - loopVideo: loopVideo, - asset: asset, - ), + child: VideoPlayerViewer( + controller: controller, + isMotionVideo: isMotionVideo, + placeholder: placeholder, + hideControlsTimer: hideControlsTimer, + showControls: showControls, + showDownloadingIndicator: showDownloadingIndicator, + loopVideo: loopVideo, ), ), ], diff --git a/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart new file mode 100644 index 0000000000000..f8e712a8b69c7 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart @@ -0,0 +1,5 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:native_video_player/native_video_player.dart'; + +final nativePlayerControllerProvider = + StateProvider((ref) => NativeVideoPlayerController(0)); diff --git a/mobile/lib/widgets/asset_viewer/native_video_player.dart b/mobile/lib/widgets/asset_viewer/native_video_player.dart new file mode 100644 index 0000000000000..26213da2cf513 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/native_video_player.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:video_player/video_player.dart'; + +class NativeVideoPlayer extends HookConsumerWidget { + final VideoPlayerController controller; + final bool isMotionVideo; + final Widget? placeholder; + final Duration hideControlsTimer; + final bool showControls; + final bool showDownloadingIndicator; + final bool loopVideo; + final Asset asset; + + const NativeVideoPlayer({ + super.key, + required this.controller, + required this.isMotionVideo, + this.placeholder, + required this.hideControlsTimer, + required this.showControls, + required this.showDownloadingIndicator, + required this.loopVideo, + required this.asset, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return NativeVideoPlayerView( + onViewReady: (controller) async { + try { + String path = ''; + VideoSourceType type = VideoSourceType.file; + if (asset.isLocal && asset.livePhotoVideoId == null) { + // Use a local file for the video player controller + final file = await asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + path = file.path; + type = VideoSourceType.file; + + final videoSource = await VideoSource.init( + path: path, + type: type, + ); + + await controller.loadVideoSource(videoSource); + await controller.play(); + + Future.delayed(const Duration(milliseconds: 100), () async { + await controller.setVolume(0.5); + }); + } + } catch (e) { + print('Error loading video: $e'); + } + }, + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart index 3e8a3c623c837..ebf158b59a5fb 100644 --- a/mobile/lib/widgets/asset_viewer/video_player.dart +++ b/mobile/lib/widgets/asset_viewer/video_player.dart @@ -1,11 +1,8 @@ import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; -import 'package:native_video_player/native_video_player.dart'; import 'package:video_player/video_player.dart'; class VideoPlayerViewer extends HookConsumerWidget { @@ -16,7 +13,6 @@ class VideoPlayerViewer extends HookConsumerWidget { final bool showControls; final bool showDownloadingIndicator; final bool loopVideo; - final Asset asset; const VideoPlayerViewer({ super.key, @@ -27,59 +23,26 @@ class VideoPlayerViewer extends HookConsumerWidget { required this.showControls, required this.showDownloadingIndicator, required this.loopVideo, - required this.asset, }); @override Widget build(BuildContext context, WidgetRef ref) { - // final chewie = useChewieController( - // controller: controller, - // controlsSafeAreaMinimum: const EdgeInsets.only( - // bottom: 100, - // ), - // placeholder: SizedBox.expand(child: placeholder), - // customControls: CustomVideoPlayerControls( - // hideTimerDuration: hideControlsTimer, - // ), - // showControls: showControls && !isMotionVideo, - // hideControlsTimer: hideControlsTimer, - // loopVideo: loopVideo, - // ); - - // return Chewie( - // controller: chewie, - // ); - - return NativeVideoPlayerView( - onViewReady: (controller) async { - try { - String path = ''; - VideoSourceType type = VideoSourceType.file; - if (asset.isLocal && asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - path = file.path; - type = VideoSourceType.file; - - final videoSource = await VideoSource.init( - path: path, - type: type, - ); - - await controller.loadVideoSource(videoSource); - await controller.play(); + final chewie = useChewieController( + controller: controller, + controlsSafeAreaMinimum: const EdgeInsets.only( + bottom: 100, + ), + placeholder: SizedBox.expand(child: placeholder), + customControls: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, + ), + showControls: showControls && !isMotionVideo, + hideControlsTimer: hideControlsTimer, + loopVideo: loopVideo, + ); - Future.delayed(const Duration(milliseconds: 100), () async { - await controller.setVolume(0.5); - }); - } - } catch (e) { - print('Error loading video: $e'); - } - }, + return Chewie( + controller: chewie, ); } } From f3362392c77b3d8b0fafec7acd89f1c35645611d Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 10 Aug 2024 10:31:10 -0500 Subject: [PATCH 03/54] stateful widget --- .../lib/pages/common/gallery_viewer.page.dart | 13 +- .../common/native_video_viewer.page.dart | 155 +++++++++++------- 2 files changed, 101 insertions(+), 67 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index cf3ae5f687803..bb44ad38011d0 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -354,8 +354,9 @@ class GalleryViewerPage extends HookConsumerWidget { ); } else { final useNativePlayer = - asset.isLocal && asset.livePhotoVideoId == null; - + a.isLocal && a.livePhotoVideoId == null; + debugPrint("asset.isLocal ${asset.isLocal}"); + debugPrint("build video player $useNativePlayer"); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, @@ -374,14 +375,6 @@ class GalleryViewerPage extends HookConsumerWidget { ? NativeVideoViewerPage( key: ValueKey(a), asset: a, - // loopVideo: shouldLoopVideo.value, - // placeholder: Image( - // image: provider, - // fit: BoxFit.contain, - // height: context.height, - // width: context.width, - // alignment: Alignment.center, - // ), ) : VideoViewerPage( key: ValueKey(a), diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 0a224089abfa9..5e7b8c71f2e16 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -27,82 +27,123 @@ class NativeVideoViewerPage extends ConsumerStatefulWidget { } class NativeVideoViewerPageState extends ConsumerState { - @override - Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - double videoWidth = size.width; - double videoHeight = size.height; - - NativeVideoPlayerController? controller; + NativeVideoPlayerController? _controller; + + bool isAutoplayEnabled = false; + bool isPlaybackLoopEnabled = false; + + double videoWidth = 0; + double videoHeight = 0; + + Future _initController(NativeVideoPlayerController controller) async { + _controller = controller; + + _controller?. // + onPlaybackStatusChanged + .addListener(_onPlaybackStatusChanged); + _controller?. // + onPlaybackPositionChanged + .addListener(_onPlaybackPositionChanged); + _controller?. // + onPlaybackSpeedChanged + .addListener(_onPlaybackSpeedChanged); + _controller?. // + onVolumeChanged + .addListener(_onPlaybackVolumeChanged); + _controller?. // + onPlaybackReady + .addListener(_onPlaybackReady); + _controller?. // + onPlaybackEnded + .addListener(_onPlaybackEnded); + + await _loadVideoSource(); + } - void initController(NativeVideoPlayerController videoCtrl) { - controller = videoCtrl; + Future _loadVideoSource() async { + final videoSource = await _createVideoSource(); + await _controller?.loadVideoSource(videoSource); + } - controller?.onPlaybackReady.addListener(() { - // Emitted when the video loaded successfully and it's ready to play. - // At this point, videoInfo is available. - final videoInfo = controller?.videoInfo; + Future _createVideoSource() async { + final file = await widget.asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } - setState(() { - if (videoInfo != null) { - videoWidth = videoInfo.width.toDouble(); - videoHeight = videoInfo.height.toDouble(); + return await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + } - print(videoHeight); - print(videoWidth); - } - }); + @override + void dispose() { + _controller?. // + onPlaybackStatusChanged + .removeListener(_onPlaybackStatusChanged); + _controller?. // + onPlaybackPositionChanged + .removeListener(_onPlaybackPositionChanged); + _controller?. // + onPlaybackSpeedChanged + .removeListener(_onPlaybackSpeedChanged); + _controller?. // + onVolumeChanged + .removeListener(_onPlaybackVolumeChanged); + _controller?. // + onPlaybackReady + .removeListener(_onPlaybackReady); + _controller?. // + onPlaybackEnded + .removeListener(_onPlaybackEnded); + _controller = null; + super.dispose(); + } - final videoDuration = videoInfo?.duration; + void _onPlaybackReady() { + final videoInfo = _controller?.videoInfo; + if (videoInfo != null) { + videoWidth = videoInfo.width.toDouble(); + videoHeight = videoInfo.height.toDouble(); + } + setState(() {}); + _controller?.play(); + } - controller?.play(); - }); + void _onPlaybackStatusChanged() { + setState(() {}); + } - controller?.onPlaybackStatusChanged.addListener(() { - final playbackStatus = controller?.playbackInfo?.status; - // playbackStatus can be playing, paused, or stopped. - }); + void _onPlaybackPositionChanged() { + setState(() {}); + } - controller?.onPlaybackPositionChanged.addListener(() { - final playbackPosition = controller?.playbackInfo?.position; - }); + void _onPlaybackSpeedChanged() { + setState(() {}); + } - controller?.onPlaybackEnded.addListener(() { - // Emitted when the video has finished playing. - }); - } + void _onPlaybackVolumeChanged() { + setState(() {}); + } - dispose() { - controller = null; - super.dispose(); + void _onPlaybackEnded() { + if (isPlaybackLoopEnabled) { + _controller?.play(); } + } + @override + Widget build(BuildContext context) { return PopScope( - onPopInvoked: (pop) { - ref.read(videoPlaybackValueProvider.notifier).value = - VideoPlaybackValue.uninitialized(); - }, + onPopInvoked: (pop) {}, child: SizedBox( height: videoHeight, width: videoWidth, child: AspectRatio( aspectRatio: 16 / 9, child: NativeVideoPlayerView( - onViewReady: (c) async { - // Use a local file for the video player controller - final file = await widget.asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - - final videoSource = await VideoSource.init( - path: file.path, - type: VideoSourceType.file, - ); - - await c.loadVideoSource(videoSource); - initController(c); - }, + onViewReady: _initController, ), ), ), From 4010481288291e96271663f6103a3d18909d6df7 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Thu, 29 Aug 2024 03:31:13 +0530 Subject: [PATCH 04/54] refactor: native_video_player --- .../lib/pages/common/gallery_viewer.page.dart | 36 +- .../common/native_video_viewer.page.dart | 308 +++++++++++------- ...tive_video_player_controller_provider.dart | 5 - .../video_player_value_provider.dart | 27 ++ .../custom_video_player_controls.dart | 10 +- .../asset_viewer/native_video_player.dart | 63 ---- mobile/lib/widgets/memories/memory_card.dart | 7 +- mobile/pubspec.lock | 9 +- mobile/pubspec.yaml | 5 +- 9 files changed, 243 insertions(+), 227 deletions(-) delete mode 100644 mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart delete mode 100644 mobile/lib/widgets/asset_viewer/native_video_player.dart diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index bb44ad38011d0..84625d7026409 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -13,7 +13,6 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; -import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; @@ -353,10 +352,6 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } else { - final useNativePlayer = - a.isLocal && a.livePhotoVideoId == null; - debugPrint("asset.isLocal ${asset.isLocal}"); - debugPrint("build video player $useNativePlayer"); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, @@ -371,24 +366,19 @@ class GalleryViewerPage extends HookConsumerWidget { maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, - child: useNativePlayer - ? NativeVideoViewerPage( - key: ValueKey(a), - asset: a, - ) - : VideoViewerPage( - key: ValueKey(a), - asset: a, - isMotionVideo: a.livePhotoVideoId != null, - loopVideo: shouldLoopVideo.value, - placeholder: Image( - image: provider, - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), - ), + child: NativeVideoViewerPage( + key: ValueKey(a), + asset: a, + isMotionVideo: a.livePhotoVideoId != null, + loopVideo: shouldLoopVideo.value, + placeholder: Image( + image: provider, + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), ); } }, diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 5e7b8c71f2e16..3a8d22d9a30dc 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -1,158 +1,226 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/asset_viewer/native_video_player_controller_provider.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_player.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:native_video_player/native_video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; -class NativeVideoViewerPage extends ConsumerStatefulWidget { +class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; + final bool isMotionVideo; final Widget? placeholder; + final bool showControls; + final Duration hideControlsTimer; + final bool loopVideo; const NativeVideoViewerPage({ super.key, required this.asset, + this.isMotionVideo = false, this.placeholder, + this.showControls = true, + this.hideControlsTimer = const Duration(seconds: 5), + this.loopVideo = false, }); @override - NativeVideoViewerPageState createState() => NativeVideoViewerPageState(); -} - -class NativeVideoViewerPageState extends ConsumerState { - NativeVideoPlayerController? _controller; - - bool isAutoplayEnabled = false; - bool isPlaybackLoopEnabled = false; - - double videoWidth = 0; - double videoHeight = 0; - - Future _initController(NativeVideoPlayerController controller) async { - _controller = controller; - - _controller?. // - onPlaybackStatusChanged - .addListener(_onPlaybackStatusChanged); - _controller?. // - onPlaybackPositionChanged - .addListener(_onPlaybackPositionChanged); - _controller?. // - onPlaybackSpeedChanged - .addListener(_onPlaybackSpeedChanged); - _controller?. // - onVolumeChanged - .addListener(_onPlaybackVolumeChanged); - _controller?. // - onPlaybackReady - .addListener(_onPlaybackReady); - _controller?. // - onPlaybackEnded - .addListener(_onPlaybackEnded); - - await _loadVideoSource(); - } - - Future _loadVideoSource() async { - final videoSource = await _createVideoSource(); - await _controller?.loadVideoSource(videoSource); - } - - Future _createVideoSource() async { - final file = await widget.asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + + Future createSource(Asset asset) async { + if (asset.isLocal && asset.livePhotoVideoId == null) { + final file = await asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + return await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + } else { + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; + + return await VideoSource.init( + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), + ); + } } - return await VideoSource.init( - path: file.path, - type: VideoSourceType.file, - ); - } + // When the volume changes, set the volume + ref.listen(videoPlayerControlsProvider.select((value) => value.mute), + (_, mute) { + if (mute) { + controller.value?.setVolume(0.0); + } else { + controller.value?.setVolume(0.7); + } + }); + + // When the position changes, seek to the position + ref.listen(videoPlayerControlsProvider.select((value) => value.position), + (_, position) { + if (controller.value == null) { + // No seeeking if there is no video + return; + } + + // Find the position to seek to + final Duration seek = asset.duration * (position / 100.0); + controller.value?.seekTo(seek.inSeconds); + }); + + // When the custom video controls paus or plays + ref.listen(videoPlayerControlsProvider.select((value) => value.pause), + (_, pause) { + if (pause) { + controller.value?.pause(); + } else { + controller.value?.play(); + } + }); + + void updateVideoPlayback() { + if (controller.value == null || !context.mounted) { + return; + } + + final videoPlayback = + VideoPlaybackValue.fromNativeController(controller.value!); + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + final state = videoPlayback.state; + + // Enable the WakeLock while the video is playing + if (state == VideoPlaybackState.playing) { + // Sync with the controls playing + WakelockPlus.enable(); + } else { + // Sync with the controls pause + WakelockPlus.disable(); + } + } - @override - void dispose() { - _controller?. // - onPlaybackStatusChanged - .removeListener(_onPlaybackStatusChanged); - _controller?. // - onPlaybackPositionChanged - .removeListener(_onPlaybackPositionChanged); - _controller?. // - onPlaybackSpeedChanged - .removeListener(_onPlaybackSpeedChanged); - _controller?. // - onVolumeChanged - .removeListener(_onPlaybackVolumeChanged); - _controller?. // - onPlaybackReady - .removeListener(_onPlaybackReady); - _controller?. // - onPlaybackEnded - .removeListener(_onPlaybackEnded); - _controller = null; - super.dispose(); - } + void onPlaybackReady() { + controller.value?.play(); + } - void _onPlaybackReady() { - final videoInfo = _controller?.videoInfo; - if (videoInfo != null) { - videoWidth = videoInfo.width.toDouble(); - videoHeight = videoInfo.height.toDouble(); + void onPlaybackPositionChanged() { + updateVideoPlayback(); } - setState(() {}); - _controller?.play(); - } - void _onPlaybackStatusChanged() { - setState(() {}); - } + void onPlaybackEnded() { + if (loopVideo) { + controller.value?.play(); + } + } - void _onPlaybackPositionChanged() { - setState(() {}); - } + Future initController(NativeVideoPlayerController nc) async { + if (controller.value != null) { + return; + } - void _onPlaybackSpeedChanged() { - setState(() {}); - } + controller.value = nc; - void _onPlaybackVolumeChanged() { - setState(() {}); - } + controller.value?.onPlaybackPositionChanged + .addListener(onPlaybackPositionChanged); + controller.value?.onPlaybackStatusChanged + .addListener(onPlaybackPositionChanged); + controller.value?.onPlaybackReady.addListener(onPlaybackReady); + controller.value?.onPlaybackEnded.addListener(onPlaybackEnded); - void _onPlaybackEnded() { - if (isPlaybackLoopEnabled) { - _controller?.play(); + final videoSource = await createSource(asset); + controller.value?.loadVideoSource(videoSource); } - } - @override - Widget build(BuildContext context) { - return PopScope( - onPopInvoked: (pop) {}, - child: SizedBox( - height: videoHeight, - width: videoWidth, - child: AspectRatio( - aspectRatio: 16 / 9, - child: NativeVideoPlayerView( - onViewReady: _initController, + useEffect( + () { + Future.microtask( + () => ref.read(videoPlayerControlsProvider.notifier).reset(), + ); + + if (isMotionVideo) { + // ignore: prefer-extracting-callbacks + Future.microtask(() { + ref.read(showControlsProvider.notifier).show = false; + }); + } + + return () { + controller.value?.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + controller.value?.onPlaybackStatusChanged + .removeListener(onPlaybackPositionChanged); + controller.value?.onPlaybackReady.removeListener(onPlaybackReady); + controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); + }; + }, + [], + ); + + void updatePlayback(VideoPlaybackValue value) => + ref.read(videoPlaybackValueProvider.notifier).value = value; + + final size = MediaQuery.sizeOf(context); + + return SizedBox( + height: size.height, + width: size.width, + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + child: PopScope( + onPopInvokedWithResult: (didPop, _) => + updatePlayback(VideoPlaybackValue.uninitialized()), + child: SizedBox( + height: size.height, + width: size.width, + child: Stack( + children: [ + Center( + child: AspectRatio( + aspectRatio: (asset.width ?? 1) / (asset.height ?? 1), + child: NativeVideoPlayerView( + onViewReady: initController, + ), + ), + ), + if (showControls) + Center( + child: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, + ), + ), + Visibility( + visible: controller.value == null, + child: Stack( + children: [ + if (placeholder != null) placeholder!, + const Positioned.fill( + child: Center( + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 500), + ), + ), + ), + ], + ), + ), + ], + ), ), ), ), ); } - // final Asset asset; - // final Widget? placeholder; - // final Duration hideControlsTimer; - // final bool showControls; - // final bool showDownloadingIndicator; - // final bool loopVideo; } diff --git a/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart deleted file mode 100644 index f8e712a8b69c7..0000000000000 --- a/mobile/lib/providers/asset_viewer/native_video_player_controller_provider.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:native_video_player/native_video_player.dart'; - -final nativePlayerControllerProvider = - StateProvider((ref) => NativeVideoPlayerController(0)); diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index ebdf739ef03de..82b971ee0c600 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:native_video_player/native_video_player.dart'; import 'package:video_player/video_player.dart'; enum VideoPlaybackState { @@ -29,6 +30,32 @@ class VideoPlaybackValue { required this.volume, }); + factory VideoPlaybackValue.fromNativeController( + NativeVideoPlayerController controller, + ) { + final playbackInfo = controller.playbackInfo; + final videoInfo = controller.videoInfo; + late VideoPlaybackState s; + if (playbackInfo?.status == null) { + s = VideoPlaybackState.initializing; + } else if (playbackInfo?.status == PlaybackStatus.stopped && + (playbackInfo?.positionFraction == 1 || + playbackInfo?.positionFraction == 0)) { + s = VideoPlaybackState.completed; + } else if (playbackInfo?.status == PlaybackStatus.playing) { + s = VideoPlaybackState.playing; + } else { + s = VideoPlaybackState.paused; + } + + return VideoPlaybackValue( + position: Duration(seconds: playbackInfo?.position ?? 0), + duration: Duration(seconds: videoInfo?.duration ?? 0), + state: s, + volume: playbackInfo?.volume ?? 0.0, + ); + } + factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { final video = controller?.value; late VideoPlaybackState s; diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index a34fcb9baf5e0..d53f268ae531a 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { final Duration hideTimerDuration; @@ -86,12 +86,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ) else GestureDetector( - onTap: () { - if (state != VideoPlaybackState.playing) { - togglePlay(); - } - ref.read(showControlsProvider.notifier).show = false; - }, + onTap: () => + ref.read(showControlsProvider.notifier).show = false, child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, diff --git a/mobile/lib/widgets/asset_viewer/native_video_player.dart b/mobile/lib/widgets/asset_viewer/native_video_player.dart deleted file mode 100644 index 26213da2cf513..0000000000000 --- a/mobile/lib/widgets/asset_viewer/native_video_player.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:native_video_player/native_video_player.dart'; -import 'package:video_player/video_player.dart'; - -class NativeVideoPlayer extends HookConsumerWidget { - final VideoPlayerController controller; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - final Asset asset; - - const NativeVideoPlayer({ - super.key, - required this.controller, - required this.isMotionVideo, - this.placeholder, - required this.hideControlsTimer, - required this.showControls, - required this.showDownloadingIndicator, - required this.loopVideo, - required this.asset, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return NativeVideoPlayerView( - onViewReady: (controller) async { - try { - String path = ''; - VideoSourceType type = VideoSourceType.file; - if (asset.isLocal && asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - path = file.path; - type = VideoSourceType.file; - - final videoSource = await VideoSource.init( - path: path, - type: type, - ); - - await controller.loadVideoSource(videoSource); - await controller.play(); - - Future.delayed(const Duration(milliseconds: 100), () async { - await controller.setVolume(0.5); - }); - } - } catch (e) { - print('Error loading video: $e'); - } - }, - ); - } -} diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index fb7cc882a0d31..138ee6debbe1c 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -2,9 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/pages/common/video_viewer.page.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; @@ -68,10 +68,9 @@ class MemoryCard extends StatelessWidget { } else { return Hero( tag: 'memory-${asset.id}', - child: VideoViewerPage( + child: NativeVideoViewerPage( key: ValueKey(asset), asset: asset, - showDownloadingIndicator: false, placeholder: SizedBox.expand( child: ImmichImage( asset, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 6b4c2703e46e2..79737f6a73893 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1027,10 +1027,11 @@ packages: native_video_player: dependency: "direct main" description: - name: native_video_player - sha256: "8df92df138c13ebf9df6b30525f9c4198534705fd450a98da14856d3a0e48cd4" - url: "https://pub.dev" - source: hosted + path: "." + ref: "feat/headers" + resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed" + url: "https://github.com/immich-app/native_video_player" + source: git version: "1.3.1" nested: dependency: transitive diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ab59f516cd707..35d986c26090d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -64,7 +64,10 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 - native_video_player: ^1.3.1 + native_video_player: + git: + url: https://github.com/immich-app/native_video_player + ref: feat/headers #image editing packages crop_image: ^1.0.13 From 57d1f78f4796f04536a142cc8503c7548c4c99ba Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Fri, 30 Aug 2024 00:16:39 +0530 Subject: [PATCH 05/54] fix: handle buffering --- .../common/native_video_viewer.page.dart | 44 ++++++++++++++++--- .../video_player_value_provider.dart | 14 ++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 3a8d22d9a30dc..4077cb7cd246b 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -33,6 +35,28 @@ class NativeVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); + final lastVideoPosition = useRef(-1); + final isBuffering = useRef(false); + + void checkIfBuffering([Timer? timer]) { + if (!context.mounted) { + timer?.cancel(); + return; + } + + final videoPlayback = ref.read(videoPlaybackValueProvider); + if ((isBuffering.value || + videoPlayback.state == VideoPlaybackState.initializing) && + videoPlayback.state != VideoPlaybackState.buffering) { + ref.read(videoPlaybackValueProvider.notifier).value = + videoPlayback.copyWith(state: VideoPlaybackState.buffering); + } + } + + // timer to mark videos as buffering if the position does not change + final bufferingTimer = useRef( + Timer.periodic(const Duration(seconds: 5), checkIfBuffering), + ); Future createSource(Asset asset) async { if (asset.isLocal && asset.livePhotoVideoId == null) { @@ -100,6 +124,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(controller.value!); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + // Check if the video is buffering + if (videoPlayback.state == VideoPlaybackState.playing) { + isBuffering.value = + lastVideoPosition.value == videoPlayback.position.inSeconds; + lastVideoPosition.value = videoPlayback.position.inSeconds; + } else { + isBuffering.value = false; + lastVideoPosition.value = -1; + } final state = videoPlayback.state; // Enable the WakeLock while the video is playing @@ -142,6 +175,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { final videoSource = await createSource(asset); controller.value?.loadVideoSource(videoSource); + + Timer(const Duration(milliseconds: 200), checkIfBuffering); } useEffect( @@ -158,6 +193,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { } return () { + bufferingTimer.value.cancel(); controller.value?.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); controller.value?.onPlaybackStatusChanged @@ -169,9 +205,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { [], ); - void updatePlayback(VideoPlaybackValue value) => - ref.read(videoPlaybackValueProvider.notifier).value = value; - final size = MediaQuery.sizeOf(context); return SizedBox( @@ -180,8 +213,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { child: GestureDetector( behavior: HitTestBehavior.deferToChild, child: PopScope( - onPopInvokedWithResult: (didPop, _) => - updatePlayback(VideoPlaybackValue.uninitialized()), + onPopInvokedWithResult: (didPop, _) => ref + .read(videoPlaybackValueProvider.notifier) + .value = VideoPlaybackValue.uninitialized(), child: SizedBox( height: size.height, width: size.width, diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index 82b971ee0c600..dad46593925cb 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -87,6 +87,20 @@ class VideoPlaybackValue { volume: 0.0, ); } + + VideoPlaybackValue copyWith({ + Duration? position, + Duration? duration, + VideoPlaybackState? state, + double? volume, + }) { + return VideoPlaybackValue( + position: position ?? this.position, + duration: duration ?? this.duration, + state: state ?? this.state, + volume: volume ?? this.volume, + ); + } } final videoPlaybackValueProvider = From 01e7274ab47dece3737d0d49c6226afb92250f6d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Aug 2024 15:03:01 -0500 Subject: [PATCH 06/54] turn on volume when video plays --- mobile/lib/pages/common/native_video_viewer.page.dart | 1 + mobile/pubspec.lock | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 4077cb7cd246b..e0cbcd0f09daf 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -147,6 +147,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { void onPlaybackReady() { controller.value?.play(); + controller.value?.setVolume(0.9); } void onPlaybackPositionChanged() { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 79737f6a73893..7c49d9384d995 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1761,10 +1761,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.2.4" wakelock_plus: dependency: "direct main" description: From 81ae5e4decbe7f373d114a1476b9f9a5d1668975 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 11 Sep 2024 02:03:14 +0530 Subject: [PATCH 07/54] fix: aspect ratio --- .../common/native_video_viewer.page.dart | 35 ++++++++++++------- mobile/pubspec.lock | 4 +-- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index e0cbcd0f09daf..df1b03f21825e 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -37,6 +37,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); + final width = useRef(asset.width?.toDouble() ?? 1.0); + final height = useRef(asset.height?.toDouble() ?? 1.0); void checkIfBuffering([Timer? timer]) { if (!context.mounted) { @@ -60,10 +62,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { Future createSource(Asset asset) async { if (asset.isLocal && asset.livePhotoVideoId == null) { - final file = await asset.local!.file; - if (file == null) { + final entity = await asset.local!.obtainForNewProperties(); + final file = await entity?.file; + if (entity == null || file == null) { throw Exception('No file found for the video'); } + + width.value = entity.orientatedWidth.toDouble(); + height.value = entity.orientatedHeight.toDouble(); + return await VideoSource.init( path: file.path, type: VideoSourceType.file, @@ -165,18 +172,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - controller.value = nc; - - controller.value?.onPlaybackPositionChanged - .addListener(onPlaybackPositionChanged); - controller.value?.onPlaybackStatusChanged - .addListener(onPlaybackPositionChanged); - controller.value?.onPlaybackReady.addListener(onPlaybackReady); - controller.value?.onPlaybackEnded.addListener(onPlaybackEnded); + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); + nc.onPlaybackReady.addListener(onPlaybackReady); + nc.onPlaybackEnded.addListener(onPlaybackEnded); final videoSource = await createSource(asset); - controller.value?.loadVideoSource(videoSource); + nc.loadVideoSource(videoSource); + controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); } @@ -206,6 +210,13 @@ class NativeVideoViewerPage extends HookConsumerWidget { [], ); + double calculateAspectRatio() { + if (width.value == 0 || height.value == 0) { + return 1; + } + return width.value / height.value; + } + final size = MediaQuery.sizeOf(context); return SizedBox( @@ -224,7 +235,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { children: [ Center( child: AspectRatio( - aspectRatio: (asset.width ?? 1) / (asset.height ?? 1), + aspectRatio: calculateAspectRatio(), child: NativeVideoPlayerView( onViewReady: initController, ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7c49d9384d995..79737f6a73893 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1761,10 +1761,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: From a2933d285f65c9a448b33c13cc78e7cd4541a28d Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 2 Oct 2024 00:12:54 +0530 Subject: [PATCH 08/54] fix: handle remote asset orientation --- mobile/lib/entities/exif_info.entity.dart | 27 ++- mobile/lib/entities/exif_info.entity.g.dart | 213 +++++++++++++++++- .../common/native_video_viewer.page.dart | 74 ++++-- .../video_player_value_provider.dart | 10 +- mobile/lib/utils/migration.dart | 2 +- 5 files changed, 295 insertions(+), 31 deletions(-) diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 63d06f5d2c1aa..583e627c5d543 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -23,6 +23,7 @@ class ExifInfo { String? state; String? country; String? description; + String? orientation; @ignore bool get hasCoordinates => @@ -45,6 +46,9 @@ class ExifInfo { @ignore String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; + @ignore + bool get isFlipped => _isOrientationFlipped(orientation); + @ignore double? get latitude => lat; @@ -67,7 +71,8 @@ class ExifInfo { city = dto.city, state = dto.state, country = dto.country, - description = dto.description; + description = dto.description, + orientation = dto.orientation; ExifInfo({ this.id, @@ -87,6 +92,7 @@ class ExifInfo { this.state, this.country, this.description, + this.orientation, }); ExifInfo copyWith({ @@ -107,6 +113,7 @@ class ExifInfo { String? state, String? country, String? description, + String? orientation, }) => ExifInfo( id: id ?? this.id, @@ -126,6 +133,7 @@ class ExifInfo { state: state ?? this.state, country: country ?? this.country, description: description ?? this.description, + orientation: orientation ?? this.orientation, ); @override @@ -147,7 +155,8 @@ class ExifInfo { city == other.city && state == other.state && country == other.country && - description == other.description; + description == other.description && + orientation == other.orientation; } @override @@ -169,7 +178,8 @@ class ExifInfo { city.hashCode ^ state.hashCode ^ country.hashCode ^ - description.hashCode; + description.hashCode ^ + orientation.hashCode; @override String toString() { @@ -192,10 +202,21 @@ class ExifInfo { state: $state, country: $country, description: $description, + orientation: $orientation }"""; } } +bool _isOrientationFlipped(String? orientation) { + final value = orientation != null ? int.tryParse(orientation) : null; + if (value == null) { + return false; + } + final isRotated90CW = value == 5 || value == 6 || value == 90; + final isRotated270CW = value == 7 || value == 8 || value == -90; + return isRotated90CW || isRotated270CW; +} + double? _exposureTimeToSeconds(String? s) { if (s == null) { return null; diff --git a/mobile/lib/entities/exif_info.entity.g.dart b/mobile/lib/entities/exif_info.entity.g.dart index 015983abf289f..0b744e5f20ae6 100644 --- a/mobile/lib/entities/exif_info.entity.g.dart +++ b/mobile/lib/entities/exif_info.entity.g.dart @@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema( name: r'model', type: IsarType.string, ), - r'state': PropertySchema( + r'orientation': PropertySchema( id: 14, + name: r'orientation', + type: IsarType.string, + ), + r'state': PropertySchema( + id: 15, name: r'state', type: IsarType.string, ), r'timeZone': PropertySchema( - id: 15, + id: 16, name: r'timeZone', type: IsarType.string, ) @@ -154,6 +159,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.orientation; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } { final value = object.state; if (value != null) { @@ -189,8 +200,9 @@ void _exifInfoSerialize( writer.writeString(offsets[11], object.make); writer.writeFloat(offsets[12], object.mm); writer.writeString(offsets[13], object.model); - writer.writeString(offsets[14], object.state); - writer.writeString(offsets[15], object.timeZone); + writer.writeString(offsets[14], object.orientation); + writer.writeString(offsets[15], object.state); + writer.writeString(offsets[16], object.timeZone); } ExifInfo _exifInfoDeserialize( @@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize( make: reader.readStringOrNull(offsets[11]), mm: reader.readFloatOrNull(offsets[12]), model: reader.readStringOrNull(offsets[13]), - state: reader.readStringOrNull(offsets[14]), - timeZone: reader.readStringOrNull(offsets[15]), + orientation: reader.readStringOrNull(offsets[14]), + state: reader.readStringOrNull(offsets[15]), + timeZone: reader.readStringOrNull(offsets[16]), ); return object; } @@ -260,6 +273,8 @@ P _exifInfoDeserializeProp

( return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; + case 16: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter }); } + QueryBuilder orientationIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'orientation', + )); + }); + } + + QueryBuilder + orientationIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'orientation', + )); + }); + } + + QueryBuilder orientationEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + orientationGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'orientation', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'orientation', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'orientation', + value: '', + )); + }); + } + + QueryBuilder + orientationIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'orientation', + value: '', + )); + }); + } + QueryBuilder stateIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByOrientation() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.asc); + }); + } + + QueryBuilder sortByOrientationDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.desc); + }); + } + QueryBuilder sortByState() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'state', Sort.asc); @@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByOrientation() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.asc); + }); + } + + QueryBuilder thenByOrientationDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.desc); + }); + } + QueryBuilder thenByState() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'state', Sort.asc); @@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByOrientation( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByState( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty }); } + QueryBuilder orientationProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'orientation'); + }); + } + QueryBuilder stateProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'state'); diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index df1b03f21825e..f6c66aa608591 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -76,6 +77,16 @@ class NativeVideoViewerPage extends HookConsumerWidget { type: VideoSourceType.file, ); } else { + final assetWithExif = + await ref.read(assetServiceProvider).loadExif(asset); + final shouldFlip = assetWithExif.exifInfo?.isFlipped ?? false; + width.value = (shouldFlip ? assetWithExif.height : assetWithExif.width) + ?.toDouble() ?? + width.value; + height.value = (shouldFlip ? assetWithExif.width : assetWithExif.height) + ?.toDouble() ?? + height.value; + // Use a network URL for the video player controller final serverEndpoint = Store.get(StoreKey.serverEndpoint); final String videoUrl = asset.livePhotoVideoId != null @@ -93,10 +104,14 @@ class NativeVideoViewerPage extends HookConsumerWidget { // When the volume changes, set the volume ref.listen(videoPlayerControlsProvider.select((value) => value.mute), (_, mute) { - if (mute) { - controller.value?.setVolume(0.0); - } else { - controller.value?.setVolume(0.7); + try { + if (mute) { + controller.value?.setVolume(0.0); + } else { + controller.value?.setVolume(0.7); + } + } catch (_) { + // Consume error from the controller } }); @@ -110,16 +125,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { // Find the position to seek to final Duration seek = asset.duration * (position / 100.0); - controller.value?.seekTo(seek.inSeconds); + try { + controller.value?.seekTo(seek.inSeconds); + } catch (_) { + // Consume error from the controller + } }); // When the custom video controls paus or plays ref.listen(videoPlayerControlsProvider.select((value) => value.pause), (_, pause) { - if (pause) { - controller.value?.pause(); - } else { - controller.value?.play(); + try { + if (pause) { + controller.value?.pause(); + } else { + controller.value?.play(); + } + } catch (_) { + // Consume error from the controller } }); @@ -153,8 +176,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { } void onPlaybackReady() { - controller.value?.play(); - controller.value?.setVolume(0.9); + try { + controller.value?.play(); + controller.value?.setVolume(0.9); + } catch (_) { + // Consume error from the controller + } } void onPlaybackPositionChanged() { @@ -162,8 +189,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { } void onPlaybackEnded() { - if (loopVideo) { - controller.value?.play(); + try { + if (loopVideo) { + controller.value?.play(); + } + } catch (_) { + // Consume error from the controller } } @@ -199,12 +230,17 @@ class NativeVideoViewerPage extends HookConsumerWidget { return () { bufferingTimer.value.cancel(); - controller.value?.onPlaybackPositionChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackStatusChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackReady.removeListener(onPlaybackReady); - controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); + try { + controller.value?.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + controller.value?.onPlaybackStatusChanged + .removeListener(onPlaybackPositionChanged); + controller.value?.onPlaybackReady.removeListener(onPlaybackReady); + controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); + controller.value?.stop(); + } catch (_) { + // Consume error from the controller + } }; }, [], diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index dad46593925cb..bffe6c7cf6f9f 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -33,8 +33,14 @@ class VideoPlaybackValue { factory VideoPlaybackValue.fromNativeController( NativeVideoPlayerController controller, ) { - final playbackInfo = controller.playbackInfo; - final videoInfo = controller.videoInfo; + PlaybackInfo? playbackInfo; + VideoInfo? videoInfo; + try { + playbackInfo = controller.playbackInfo; + videoInfo = controller.videoInfo; + } catch (_) { + // Consume error from the controller + } late VideoPlaybackState s; if (playbackInfo?.status == null) { s = VideoPlaybackState.initializing; diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 2b02a5ff8f290..67ff060075383 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; -const int targetVersion = 6; +const int targetVersion = 7; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, 1); From ef765105a1bbc8463b410e097f8a60cc0a9a78a1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:14:35 -0500 Subject: [PATCH 09/54] refinements and fixes fix orientation for remote assets wip separate widget separate video loader widget fixed memory leak optimized seeking, cleanup debug context pop use global key back to one widget fixed rebuild wait for swipe animation to finish smooth hero animation for remote videos faster scroll animation --- mobile/lib/entities/asset.entity.dart | 37 +- mobile/lib/entities/exif_info.entity.dart | 5 +- mobile/lib/extensions/scroll_extensions.dart | 32 ++ .../lib/pages/common/gallery_viewer.page.dart | 313 ++++++----- .../common/native_video_viewer.page.dart | 487 +++++++++++------- .../lib/pages/common/video_viewer.page.dart | 3 +- .../video_player_controls_provider.dart | 45 +- .../video_player_value_provider.dart | 66 ++- mobile/lib/utils/hooks/interval_hook.dart | 18 + .../custom_video_player_controls.dart | 15 +- .../asset_viewer/detail_panel/file_info.dart | 7 +- mobile/lib/widgets/memories/memory_card.dart | 6 +- 12 files changed, 611 insertions(+), 423 deletions(-) create mode 100644 mobile/lib/extensions/scroll_extensions.dart create mode 100644 mobile/lib/utils/hooks/interval_hook.dart diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 182c10307fdef..2f04f61c5a6b9 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -22,12 +22,8 @@ class Asset { durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), fileName = remote.originalFileName, - height = isFlipped(remote) - ? remote.exifInfo?.exifImageWidth?.toInt() - : remote.exifInfo?.exifImageHeight?.toInt(), - width = isFlipped(remote) - ? remote.exifInfo?.exifImageHeight?.toInt() - : remote.exifInfo?.exifImageWidth?.toInt(), + height = remote.exifInfo?.exifImageHeight?.toInt(), + width = remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, ownerId = fastHash(remote.ownerId), exifInfo = @@ -172,6 +168,9 @@ class Asset { @ignore bool get isImage => type == AssetType.image; + @ignore + bool get isMotionPhoto => livePhotoVideoId != null; + @ignore AssetState get storage { if (isRemote && isLocal) { @@ -192,6 +191,14 @@ class Asset { @ignore set byteHash(List hash) => checksum = base64.encode(hash); + @ignore + int? get orientatedWidth => + exifInfo != null && exifInfo!.isFlipped ? height : width; + + @ignore + int? get orientatedHeight => + exifInfo != null && exifInfo!.isFlipped ? width : height; + @override bool operator ==(other) { if (other is! Asset) return false; @@ -511,21 +518,3 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } - -/// Returns `true` if this [int] is flipped 90° clockwise -bool isRotated90CW(int orientation) { - return [7, 8, -90].contains(orientation); -} - -/// Returns `true` if this [int] is flipped 270° clockwise -bool isRotated270CW(int orientation) { - return [5, 6, 90].contains(orientation); -} - -/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise -bool isFlipped(AssetResponseDto response) { - final int orientation = - int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0; - return orientation != 0 && - (isRotated90CW(orientation) || isRotated270CW(orientation)); -} diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 583e627c5d543..7a0db3fdeb197 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -47,7 +47,10 @@ class ExifInfo { String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; @ignore - bool get isFlipped => _isOrientationFlipped(orientation); + bool? _isFlipped; + + @ignore + bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation); @ignore double? get latitude => lat; diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart new file mode 100644 index 0000000000000..838c2afd3ce8d --- /dev/null +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -0,0 +1,32 @@ +import 'package:flutter/cupertino.dart'; + +const _spring = SpringDescription( + mass: 40, + stiffness: 100, + damping: 1, +); + +// https://stackoverflow.com/a/74453792 +class FastScrollPhysics extends ScrollPhysics { + const FastScrollPhysics({super.parent}); + + @override + FastScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => _spring; +} + +class FastClampingScrollPhysics extends ClampingScrollPhysics { + const FastClampingScrollPhysics({super.parent}); + + @override + FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastClampingScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => _spring; +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 84625d7026409..05524843272f5 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; @@ -53,21 +54,15 @@ class GalleryViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(appSettingsServiceProvider); - final loadAsset = renderList.loadAsset; final totalAssets = useState(renderList.totalAssets); - final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue); final isZoomed = useState(false); - final isPlayingVideo = useState(false); - final localPosition = useState(null); - final currentIndex = useState(initialIndex); + final isPlayingMotionVideo = useState(false); + final stackIndex = useState(-1); + final localPosition = useRef(null); + final currentIndex = useValueNotifier(initialIndex); + final loadAsset = renderList.loadAsset; final currentAsset = loadAsset(currentIndex.value); - // Update is playing motion video - ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { - isPlayingVideo.value = state == VideoPlaybackState.playing; - }); - final stackIndex = useState(-1); final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; @@ -79,30 +74,23 @@ class GalleryViewerPage extends HookConsumerWidget { ? currentAsset : stackElements.elementAt(stackIndex.value); - final isMotionPhoto = asset.livePhotoVideoId != null; - // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page - ref.listen(currentAssetProvider, (_, __) {}); - useEffect( - () { - // Delay state update to after the execution of build method - Future.microtask( - () => ref.read(currentAssetProvider.notifier).set(asset), - ); - return null; - }, - [asset], - ); - - useEffect( - () { - shouldLoopVideo.value = - settings.getSetting(AppSettingsEnum.loopVideo); - return null; - }, - [], - ); + // // Update is playing motion video + if (asset.isMotionPhoto) { + ref.listen( + videoPlaybackValueProvider.select( + (playback) => playback.state == VideoPlaybackState.playing, + ), (wasPlaying, isPlaying) { + if (wasPlaying != null && wasPlaying && !isPlaying) { + isPlayingMotionVideo.value = false; + } + }); + } Future precacheNextImage(int index) async { + if (!context.mounted) { + return; + } + void onError(Object exception, StackTrace? stackTrace) { // swallow error silently debugPrint('Error precaching next image: $exception, $stackTrace'); @@ -110,6 +98,7 @@ class GalleryViewerPage extends HookConsumerWidget { try { if (index < totalAssets.value && index >= 0) { + log.info('Precaching next image at index $index'); final asset = loadAsset(index); await precacheImage( ImmichImage.imageProvider(asset: asset), @@ -124,6 +113,27 @@ class GalleryViewerPage extends HookConsumerWidget { } } + // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page + ref.listen(currentAssetProvider, (prev, cur) { + log.info('Current asset changed from ${prev?.id} to ${cur?.id}'); + }); + + useEffect(() { + ref.read(currentAssetProvider.notifier).set(asset); + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + + // Delay this a bit so we can finish loading the page + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(currentIndex.value + 1); + }); + + return null; + }); + void showInfo() { showModalBottomSheet( shape: const RoundedRectangleBorder( @@ -182,34 +192,6 @@ class GalleryViewerPage extends HookConsumerWidget { } } - useEffect( - () { - if (ref.read(showControlsProvider)) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - isPlayingVideo.value = false; - return null; - }, - [], - ); - - useEffect( - () { - // No need to await this - unawaited( - // Delay this a bit so we can finish loading the page - Future.delayed(const Duration(milliseconds: 400)).then( - // Precache the next image - (_) => precacheNextImage(currentIndex.value + 1), - ), - ); - return null; - }, - [], - ); - ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -219,7 +201,12 @@ class GalleryViewerPage extends HookConsumerWidget { }); Widget buildStackedChildren() { + if (!showStack) { + return const SizedBox(); + } + return ListView.builder( + key: ValueKey(currentAsset), shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, @@ -230,7 +217,11 @@ class GalleryViewerPage extends HookConsumerWidget { ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; + if (assetId == null) { + return const SizedBox(); + } return Padding( + key: ValueKey(assetId), padding: const EdgeInsets.only(right: 5), child: GestureDetector( onTap: () => stackIndex.value = index, @@ -252,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget { borderRadius: BorderRadius.circular(4), child: Image( fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId!), + image: ImmichRemoteImageProvider(assetId: assetId), ), ), ), @@ -262,6 +253,95 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + Object getHeroTag(Asset asset) { + return isFromDto + ? '${asset.remoteId}-$heroOffset' + : asset.id + heroOffset; + } + + PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { + return PhotoViewGalleryPageOptions( + onDragStart: (_, details, __) { + localPosition.value = details.localPosition; + }, + onDragUpdate: (_, details, __) { + handleSwipeUpDown(details); + }, + onTapDown: (_, __, ___) { + ref.read(showControlsProvider.notifier).toggle(); + }, + onLongPressStart: (_, __, ___) { + if (asset.livePhotoVideoId != null) { + isPlayingMotionVideo.value = true; + } + }, + imageProvider: ImmichImage.imageProvider(asset: asset), + heroAttributes: PhotoViewHeroAttributes( + tag: getHeroTag(asset), + transitionOnUserGestures: true, + ), + filterQuality: FilterQuality.high, + tightMode: true, + minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) => ImmichImage( + asset, + fit: BoxFit.contain, + ), + ); + } + + PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { + final key = GlobalKey(); + final tag = getHeroTag(asset); + return PhotoViewGalleryPageOptions.customChild( + onDragStart: (_, details, __) => + localPosition.value = details.localPosition, + onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + heroAttributes: PhotoViewHeroAttributes( + tag: tag, + transitionOnUserGestures: true, + ), + filterQuality: FilterQuality.high, + initialScale: 1.0, + maxScale: 1.0, + minScale: 1.0, + basePosition: Alignment.center, + child: Hero( + tag: tag, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: key, + asset: asset, + placeholder: Image( + key: ValueKey(asset), + image: ImmichImage.imageProvider(asset: asset), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), + ), + ), + ); + } + + PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { + final newAsset = index == currentIndex.value ? asset : loadAsset(index); + + if (newAsset.isImage) { + ref.read(showControlsProvider.notifier).show = false; + } + + if (newAsset.isImage && !isPlayingMotionVideo.value) { + return buildImage(context, newAsset); + } + log.info('Loading asset ${newAsset.id} (index $index) as video'); + return buildVideo(context, newAsset); + } + return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -271,10 +351,13 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( + key: ValueKey(asset), scaleStateChangedCallback: (state) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, + // wantKeepAlive: true, + gaplessPlayback: true, loadingBuilder: (context, event, index) => ClipRect( child: Stack( fit: StackFit.expand, @@ -286,6 +369,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), ImmichThumbnail( + key: ValueKey(asset), asset: asset, fit: BoxFit.contain, ), @@ -296,92 +380,40 @@ class GalleryViewerPage extends HookConsumerWidget { scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in : (Platform.isIOS - ? const ScrollPhysics() // Use bouncing physics for iOS - : const ClampingScrollPhysics() // Use heavy physics for Android + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics() // Use heavy physics for Android ), itemCount: totalAssets.value, scrollDirection: Axis.horizontal, - onPageChanged: (value) async { + onPageChanged: (value) { + log.info('Page changed to $value'); final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); + final newAsset = + value == currentIndex.value ? asset : loadAsset(value); + if (!newAsset.isImage || newAsset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + currentIndex.value = value; stackIndex.value = -1; - isPlayingVideo.value = false; + isPlayingMotionVideo.value = false; - // Wait for page change animation to finish - await Future.delayed(const Duration(milliseconds: 400)); - // Then precache the next image - unawaited(precacheNextImage(next)); - }, - builder: (context, index) { - final a = - index == currentIndex.value ? asset : loadAsset(index); - - final ImageProvider provider = - ImmichImage.imageProvider(asset: a); - - if (a.isImage && !isPlayingVideo.value) { - return PhotoViewGalleryPageOptions( - onDragStart: (_, details, __) => - localPosition.value = details.localPosition, - onDragUpdate: (_, details, __) => - handleSwipeUpDown(details), - onTapDown: (_, __, ___) { - ref.read(showControlsProvider.notifier).toggle(); - }, - onLongPressStart: (_, __, ___) { - if (asset.livePhotoVideoId != null) { - isPlayingVideo.value = true; - } - }, - imageProvider: provider, - heroAttributes: PhotoViewHeroAttributes( - tag: isFromDto - ? '${currentAsset.remoteId}-$heroOffset' - : currentAsset.id + heroOffset, - transitionOnUserGestures: true, - ), - filterQuality: FilterQuality.high, - tightMode: true, - minScale: PhotoViewComputedScale.contained, - errorBuilder: (context, error, stackTrace) => ImmichImage( - a, - fit: BoxFit.contain, - ), - ); - } else { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: (_, details, __) => - localPosition.value = details.localPosition, - onDragUpdate: (_, details, __) => - handleSwipeUpDown(details), - heroAttributes: PhotoViewHeroAttributes( - tag: isFromDto - ? '${currentAsset.remoteId}-$heroOffset' - : currentAsset.id + heroOffset, - ), - filterQuality: FilterQuality.high, - maxScale: 1.0, - minScale: 1.0, - basePosition: Alignment.center, - child: NativeVideoViewerPage( - key: ValueKey(a), - asset: a, - isMotionVideo: a.livePhotoVideoId != null, - loopVideo: shouldLoopVideo.value, - placeholder: Image( - image: provider, - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), - ), - ); - } + // Delay setting the new asset to avoid a stutter in the page change animation + // TODO: make the scroll animation finish more quickly, and ideally have a callback for when it's done + ref.read(currentAssetProvider.notifier).set(newAsset); + // Timer(const Duration(milliseconds: 450), () { + // ref.read(currentAssetProvider.notifier).set(newAsset); + // }); + + // Wait for page change animation to finish, then precache the next image + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(next); + }); }, + builder: buildAsset, ), Positioned( top: 0, @@ -390,9 +422,9 @@ class GalleryViewerPage extends HookConsumerWidget { child: GalleryAppBar( asset: asset, showInfo: showInfo, - isPlayingVideo: isPlayingVideo.value, + isPlayingVideo: isPlayingMotionVideo.value, onToggleMotionVideo: () => - isPlayingVideo.value = !isPlayingVideo.value, + isPlayingMotionVideo.value = !isPlayingMotionVideo.value, ), ), Positioned( @@ -416,7 +448,8 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex: stackIndex.value, asset: asset, assetIndex: currentIndex, - showVideoPlayerControls: !asset.isImage && !isMotionPhoto, + showVideoPlayerControls: + !asset.isImage && !asset.isMotionPhoto, ), ], ), diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index f6c66aa608591..de2fad12a3103 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -5,304 +5,429 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; +import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; +import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; +import 'package:photo_manager/photo_manager.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; - final bool isMotionVideo; - final Widget? placeholder; final bool showControls; final Duration hideControlsTimer; - final bool loopVideo; + final Widget placeholder; + // final ValueNotifier? doInitialize; const NativeVideoViewerPage({ super.key, required this.asset, - this.isMotionVideo = false, - this.placeholder, + required this.placeholder, this.showControls = true, this.hideControlsTimer = const Duration(seconds: 5), - this.loopVideo = false, }); @override Widget build(BuildContext context, WidgetRef ref) { + final loopVideo = ref.watch( + appSettingsServiceProvider.select( + (settings) => settings.getSetting(AppSettingsEnum.loopVideo), + ), + ); final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); - final width = useRef(asset.width?.toDouble() ?? 1.0); - final height = useRef(asset.height?.toDouble() ?? 1.0); + final currentAsset = useState(ref.read(currentAssetProvider)); + final isCurrent = currentAsset.value == asset; + + final log = Logger('NativeVideoViewerPage'); + log.info('Building NativeVideoViewerPage'); + + final localEntity = useMemoized(() { + if (!asset.isLocal) { + return null; + } + + return AssetEntity.fromId(asset.localId!); + }); - void checkIfBuffering([Timer? timer]) { + Future calculateAspectRatio() async { if (!context.mounted) { - timer?.cancel(); - return; + log.info('calculateAspectRatio: Context is not mounted'); + return null; } - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || - videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = - videoPlayback.copyWith(state: VideoPlaybackState.buffering); + log.info('Calculating aspect ratio'); + late final double? orientatedWidth; + late final double? orientatedHeight; + + if (asset.exifInfo != null) { + orientatedWidth = asset.orientatedWidth?.toDouble(); + orientatedHeight = asset.orientatedHeight?.toDouble(); + } else if (localEntity != null) { + final entity = await localEntity; + orientatedWidth = entity?.orientatedWidth.toDouble(); + orientatedHeight = entity?.orientatedHeight.toDouble(); + } else { + final entity = await ref.read(assetServiceProvider).loadExif(asset); + orientatedWidth = entity.orientatedWidth?.toDouble(); + orientatedHeight = entity.orientatedHeight?.toDouble(); + } + + log.info('Calculated aspect ratio'); + if (orientatedWidth != null && + orientatedHeight != null && + orientatedWidth > 0 && + orientatedHeight > 0) { + return orientatedWidth / orientatedHeight; } + + return 1.0; } - // timer to mark videos as buffering if the position does not change - final bufferingTimer = useRef( - Timer.periodic(const Duration(seconds: 5), checkIfBuffering), - ); + Future createSource() async { + if (!context.mounted) { + log.info('createSource: Context is not mounted'); + return null; + } - Future createSource(Asset asset) async { - if (asset.isLocal && asset.livePhotoVideoId == null) { - final entity = await asset.local!.obtainForNewProperties(); - final file = await entity?.file; - if (entity == null || file == null) { + if (localEntity != null && asset.livePhotoVideoId == null) { + log.info('Loading video from local storage'); + + final file = await (await localEntity)!.file; + if (file == null) { throw Exception('No file found for the video'); } - width.value = entity.orientatedWidth.toDouble(); - height.value = entity.orientatedHeight.toDouble(); - - return await VideoSource.init( + final source = await VideoSource.init( path: file.path, type: VideoSourceType.file, ); - } else { - final assetWithExif = - await ref.read(assetServiceProvider).loadExif(asset); - final shouldFlip = assetWithExif.exifInfo?.isFlipped ?? false; - width.value = (shouldFlip ? assetWithExif.height : assetWithExif.width) - ?.toDouble() ?? - width.value; - height.value = (shouldFlip ? assetWithExif.width : assetWithExif.height) - ?.toDouble() ?? - height.value; - - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; - - return await VideoSource.init( - path: videoUrl, - type: VideoSourceType.network, - headers: ApiService.getRequestHeaders(), - ); + log.info('Loaded video from local storage'); + return source; + } + + log.info('Loading video from server'); + + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; + + final source = await VideoSource.init( + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), + ); + log.info('Loaded video from server'); + return source; + } + + final videoSource = useState(null); + final aspectRatio = useState(null); + useMemoized( + () async { + if (!context.mounted) { + log.info('combined: Context is not mounted'); + return null; + } + + final (videoSourceRes, aspectRatioRes) = + await (createSource(), calculateAspectRatio()).wait; + if (videoSourceRes == null || aspectRatioRes == null) { + log.info('combined: Video source or aspect ratio is null'); + return; + } + + // if opening a remote video from a hero animation, delay initialization to avoid a stutter + if (!asset.isLocal && isCurrent) { + await Future.delayed(const Duration(milliseconds: 150)); + } + + videoSource.value = videoSourceRes; + aspectRatio.value = aspectRatioRes; + }, + ); + + void checkIfBuffering() { + if (!context.mounted) { + return; + } + + log.info('Checking if buffering'); + final videoPlayback = ref.read(videoPlaybackValueProvider); + if ((isBuffering.value || + videoPlayback.state == VideoPlaybackState.initializing) && + videoPlayback.state != VideoPlaybackState.buffering) { + log.info('Marking video as buffering'); + ref.read(videoPlaybackValueProvider.notifier).value = + videoPlayback.copyWith(state: VideoPlaybackState.buffering); } } + // timer to mark videos as buffering if the position does not change + useInterval(const Duration(seconds: 5), checkIfBuffering); + // When the volume changes, set the volume ref.listen(videoPlayerControlsProvider.select((value) => value.mute), (_, mute) { + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { + return; + } + try { - if (mute) { - controller.value?.setVolume(0.0); - } else { - controller.value?.setVolume(0.7); + if (mute && playbackInfo.volume != 0.0) { + playerController.setVolume(0.0); + } else if (!mute && playbackInfo.volume != 0.7) { + playerController.setVolume(0.7); } - } catch (_) { - // Consume error from the controller + } catch (error) { + log.severe('Error setting volume: $error'); } }); // When the position changes, seek to the position ref.listen(videoPlayerControlsProvider.select((value) => value.position), (_, position) { - if (controller.value == null) { - // No seeeking if there is no video + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { return; } // Find the position to seek to - final Duration seek = asset.duration * (position / 100.0); - try { - controller.value?.seekTo(seek.inSeconds); - } catch (_) { - // Consume error from the controller + final int seek = (asset.duration * (position / 100.0)).inSeconds; + if (seek != playbackInfo.position) { + try { + playerController.seekTo(seek); + } catch (error) { + log.severe('Error seeking to position $position: $error'); + } } + + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: seek); }); - // When the custom video controls paus or plays + // // When the custom video controls pause or play ref.listen(videoPlayerControlsProvider.select((value) => value.pause), (_, pause) { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + try { if (pause) { - controller.value?.pause(); + log.info('Pausing video'); + videoController.pause(); } else { - controller.value?.play(); + log.info('Playing video'); + videoController.play(); } - } catch (_) { - // Consume error from the controller + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + }); + + void onPlaybackReady() { + final videoController = controller.value; + if (videoController == null || !isCurrent || !context.mounted) { + return; } + + log.info('Playback ready for video ${asset.id}'); + + try { + videoController.play(); + videoController.setVolume(0.9); + } catch (error) { + log.severe('Error playing video: $error'); + } + } + + ref.listen(currentAssetProvider, (_, value) { + log.info( + 'Changing currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}', + ); + // Delay the video playback to avoid a stutter in the swipe animation + Timer(const Duration(milliseconds: 350), () { + if (!context.mounted) { + return; + } + + log.info( + 'Changed currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}', + ); + currentAsset.value = value; + if (currentAsset.value == asset) { + onPlaybackReady(); + } + }); }); - void updateVideoPlayback() { - if (controller.value == null || !context.mounted) { + void onPlaybackStatusChanged() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { return; } final videoPlayback = - VideoPlaybackValue.fromNativeController(controller.value!); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - // Check if the video is buffering - if (videoPlayback.state == VideoPlaybackState.playing) { - isBuffering.value = - lastVideoPosition.value == videoPlayback.position.inSeconds; - lastVideoPosition.value = videoPlayback.position.inSeconds; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; + VideoPlaybackValue.fromNativeController(videoController); + if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) { + return; } - final state = videoPlayback.state; + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - // Enable the WakeLock while the video is playing - if (state == VideoPlaybackState.playing) { + if (videoPlayback.state == VideoPlaybackState.playing) { // Sync with the controls playing WakelockPlus.enable(); + log.info('Video ${asset.id} is playing; enabled wakelock'); } else { // Sync with the controls pause WakelockPlus.disable(); + log.info('Video ${asset.id} is not playing; disabled wakelock'); } } - void onPlaybackReady() { - try { - controller.value?.play(); - controller.value?.setVolume(0.9); - } catch (_) { - // Consume error from the controller + void onPlaybackPositionChanged() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; } - } - void onPlaybackPositionChanged() { - updateVideoPlayback(); + final playbackInfo = videoController.playbackInfo; + if (playbackInfo == null) { + return; + } + + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: playbackInfo.position); + + // Check if the video is buffering + if (playbackInfo.status == PlaybackStatus.playing) { + isBuffering.value = lastVideoPosition.value == playbackInfo.position; + lastVideoPosition.value = playbackInfo.position; + } else { + isBuffering.value = false; + lastVideoPosition.value = -1; + } } void onPlaybackEnded() { - try { - if (loopVideo) { - controller.value?.play(); + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + if (loopVideo) { + try { + videoController.play(); + } catch (error) { + log.severe('Error looping video: $error'); } - } catch (_) { - // Consume error from the controller + } else { + WakelockPlus.disable(); } } - Future initController(NativeVideoPlayerController nc) async { + void initController(NativeVideoPlayerController nc) { + log.info('initController for ${asset.id} started'); if (controller.value != null) { + log.info( + 'initController for ${asset.id}: Controller already initialized'); return; } + ref.read(videoPlayerControlsProvider.notifier).reset(); + ref.read(videoPlaybackValueProvider.notifier).reset(); nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); - nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); - final videoSource = await createSource(asset); - nc.loadVideoSource(videoSource); + nc.loadVideoSource(videoSource.value!); + log.info('initController for ${asset.id}: setting controller'); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); } useEffect( () { - Future.microtask( - () => ref.read(videoPlayerControlsProvider.notifier).reset(), - ); - - if (isMotionVideo) { - // ignore: prefer-extracting-callbacks - Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; - }); - } - return () { - bufferingTimer.value.cancel(); + log.info('Cleaning up video ${asset.id}'); + final playerController = controller.value; + if (playerController == null) { + log.info('Controller is null'); + return; + } + try { - controller.value?.onPlaybackPositionChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackStatusChanged + playerController.stop(); + + playerController.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackReady.removeListener(onPlaybackReady); - controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); - controller.value?.stop(); - } catch (_) { - // Consume error from the controller + playerController.onPlaybackStatusChanged + .removeListener(onPlaybackStatusChanged); + playerController.onPlaybackReady.removeListener(onPlaybackReady); + playerController.onPlaybackEnded.removeListener(onPlaybackEnded); + } catch (error) { + log.severe('Error during useEffect cleanup: $error'); } + + controller.value = null; + WakelockPlus.disable(); }; }, - [], + [videoSource], ); - double calculateAspectRatio() { - if (width.value == 0 || height.value == 0) { - return 1; - } - return width.value / height.value; - } - - final size = MediaQuery.sizeOf(context); - - return SizedBox( - height: size.height, - width: size.width, - child: GestureDetector( - behavior: HitTestBehavior.deferToChild, - child: PopScope( - onPopInvokedWithResult: (didPop, _) => ref - .read(videoPlaybackValueProvider.notifier) - .value = VideoPlaybackValue.uninitialized(), - child: SizedBox( - height: size.height, - width: size.width, - child: Stack( - children: [ - Center( - child: AspectRatio( - aspectRatio: calculateAspectRatio(), - child: NativeVideoPlayerView( - onViewReady: initController, - ), - ), - ), - if (showControls) - Center( - child: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, - ), - ), - Visibility( - visible: controller.value == null, - child: Stack( - children: [ - if (placeholder != null) placeholder!, - const Positioned.fill( - child: Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 500), - ), - ), - ), - ], - ), - ), - ], + return Stack( + children: [ + placeholder, + Center( + key: ValueKey('player-${asset.hashCode}'), + child: aspectRatio.value != null + ? AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ) + : null, + ), + // covers the video with the placeholder + if (showControls) + Center( + key: ValueKey('controls-${asset.hashCode}'), + child: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, ), ), - ), - ), + ], ); } } diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index d605d894362ee..1929043362613 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -123,8 +123,7 @@ class VideoViewerPage extends HookConsumerWidget { return PopScope( onPopInvokedWithResult: (didPop, _) { - ref.read(videoPlaybackValueProvider.notifier).value = - VideoPlaybackValue.uninitialized(); + ref.read(videoPlaybackValueProvider.notifier).reset(); }, child: AnimatedSwitcher( duration: const Duration(milliseconds: 400), diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index d15b26ea20994..20f8fd7d2e5db 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -1,7 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; class VideoPlaybackControls { - VideoPlaybackControls({ + const VideoPlaybackControls({ required this.position, required this.mute, required this.pause, @@ -17,15 +17,14 @@ final videoPlayerControlsProvider = return VideoPlayerControls(ref); }); +const videoPlayerControlsDefault = VideoPlaybackControls( + position: 0, + pause: false, + mute: false, +); + class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) - : super( - VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ), - ); + VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); final Ref ref; @@ -36,17 +35,17 @@ class VideoPlayerControls extends StateNotifier { } void reset() { - state = VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ); + state = videoPlayerControlsDefault; } double get position => state.position; bool get mute => state.mute; set position(double value) { + if (state.position == value) { + return; + } + state = VideoPlaybackControls( position: value, mute: state.mute, @@ -55,6 +54,10 @@ class VideoPlayerControls extends StateNotifier { } set mute(bool value) { + if (state.mute == value) { + return; + } + state = VideoPlaybackControls( position: state.position, mute: value, @@ -71,6 +74,10 @@ class VideoPlayerControls extends StateNotifier { } void pause() { + if (state.pause) { + return; + } + state = VideoPlaybackControls( position: state.position, mute: state.mute, @@ -79,6 +86,10 @@ class VideoPlayerControls extends StateNotifier { } void play() { + if (!state.pause) { + return; + } + state = VideoPlaybackControls( position: state.position, mute: state.mute, @@ -95,12 +106,6 @@ class VideoPlayerControls extends StateNotifier { } void restart() { - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: true, - ); - state = VideoPlaybackControls( position: 0, mute: state.mute, diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index bffe6c7cf6f9f..b4f690fa22f0f 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -23,7 +23,7 @@ class VideoPlaybackValue { /// The volume of the video final double volume; - VideoPlaybackValue({ + const VideoPlaybackValue({ required this.position, required this.duration, required this.state, @@ -33,32 +33,24 @@ class VideoPlaybackValue { factory VideoPlaybackValue.fromNativeController( NativeVideoPlayerController controller, ) { - PlaybackInfo? playbackInfo; - VideoInfo? videoInfo; - try { - playbackInfo = controller.playbackInfo; - videoInfo = controller.videoInfo; - } catch (_) { - // Consume error from the controller - } - late VideoPlaybackState s; - if (playbackInfo?.status == null) { - s = VideoPlaybackState.initializing; - } else if (playbackInfo?.status == PlaybackStatus.stopped && - (playbackInfo?.positionFraction == 1 || - playbackInfo?.positionFraction == 0)) { - s = VideoPlaybackState.completed; - } else if (playbackInfo?.status == PlaybackStatus.playing) { - s = VideoPlaybackState.playing; - } else { - s = VideoPlaybackState.paused; + final playbackInfo = controller.playbackInfo; + final videoInfo = controller.videoInfo; + + if (playbackInfo == null || videoInfo == null) { + return videoPlaybackValueDefault; } + final VideoPlaybackState status = switch (playbackInfo.status) { + PlaybackStatus.playing => VideoPlaybackState.playing, + PlaybackStatus.paused => VideoPlaybackState.paused, + PlaybackStatus.stopped => VideoPlaybackState.completed, + }; + return VideoPlaybackValue( - position: Duration(seconds: playbackInfo?.position ?? 0), - duration: Duration(seconds: videoInfo?.duration ?? 0), - state: s, - volume: playbackInfo?.volume ?? 0.0, + position: Duration(seconds: playbackInfo.position), + duration: Duration(seconds: videoInfo.duration), + state: status, + volume: playbackInfo.volume, ); } @@ -85,15 +77,6 @@ class VideoPlaybackValue { ); } - factory VideoPlaybackValue.uninitialized() { - return VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, - ); - } - VideoPlaybackValue copyWith({ Duration? position, Duration? duration, @@ -109,16 +92,20 @@ class VideoPlaybackValue { } } +const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, +); + final videoPlaybackValueProvider = StateNotifierProvider((ref) { return VideoPlaybackValueState(ref); }); class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) - : super( - VideoPlaybackValue.uninitialized(), - ); + VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); final Ref ref; @@ -129,6 +116,7 @@ class VideoPlaybackValueState extends StateNotifier { } set position(Duration value) { + if (state.position == value) return; state = VideoPlaybackValue( position: value, duration: state.duration, @@ -136,4 +124,8 @@ class VideoPlaybackValueState extends StateNotifier { volume: state.volume, ); } + + void reset() { + state = videoPlaybackValueDefault; + } } diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart new file mode 100644 index 0000000000000..0c346065f7210 --- /dev/null +++ b/mobile/lib/utils/hooks/interval_hook.dart @@ -0,0 +1,18 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_hooks/flutter_hooks.dart'; + +// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638 +void useInterval(Duration delay, VoidCallback callback) { + final savedCallback = useRef(callback); + savedCallback.value = callback; + + useEffect( + () { + final timer = Timer.periodic(delay, (_) => savedCallback.value()); + return timer.cancel; + }, + [delay], + ); +} diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index d53f268ae531a..2fd0a38edc537 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; @@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { } }, ); - - final showBuffering = useState(false); final VideoPlaybackState state = - ref.watch(videoPlaybackValueProvider).state; + ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final showBuffering = state == VideoPlaybackState.buffering; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { showControlsAndStartHideTimer(); }); - ref.listen(videoPlaybackValueProvider.select((value) => value.state), - (_, state) { - // Show buffering - showBuffering.value = state == VideoPlaybackState.buffering; - }); - /// Toggles between playing and pausing depending on the state of the video void togglePlay() { showControlsAndStartHideTimer(); - final state = ref.read(videoPlaybackValueProvider).state; if (state == VideoPlaybackState.playing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } else if (state == VideoPlaybackState.completed) { @@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { absorbing: !ref.watch(showControlsProvider), child: Stack( children: [ - if (showBuffering.value) + if (showBuffering) const Center( child: DelayedLoadingIndicator( fadeInDuration: Duration(milliseconds: 400), diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 3c650bdc6a2e3..b2a010754675a 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget { Widget build(BuildContext context) { final textColor = context.isDarkTheme ? Colors.white : Colors.black; - String resolution = asset.width != null && asset.height != null - ? "${asset.height} x ${asset.width} " - : ""; + String resolution = + asset.orientatedHeight != null && asset.orientatedWidth != null + ? "${asset.orientatedHeight} x ${asset.orientatedWidth} " + : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 138ee6debbe1c..de331670abf2e 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -69,16 +69,16 @@ class MemoryCard extends StatelessWidget { return Hero( tag: 'memory-${asset.id}', child: NativeVideoViewerPage( - key: ValueKey(asset), + key: ValueKey(asset.id), asset: asset, + hideControlsTimer: const Duration(seconds: 2), + showControls: false, placeholder: SizedBox.expand( child: ImmichImage( asset, fit: fit, ), ), - hideControlsTimer: const Duration(seconds: 2), - showControls: false, ), ); } From 026e7f75fed07ff03465e209036c73237a42c20d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:24:37 -0500 Subject: [PATCH 10/54] clean up logging --- .../lib/pages/common/gallery_viewer.page.dart | 11 ++---- .../common/native_video_viewer.page.dart | 34 ------------------- 2 files changed, 3 insertions(+), 42 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 05524843272f5..73cb0cf4f854b 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -93,12 +93,11 @@ class GalleryViewerPage extends HookConsumerWidget { void onError(Object exception, StackTrace? stackTrace) { // swallow error silently - debugPrint('Error precaching next image: $exception, $stackTrace'); + log.severe('Error precaching next image: $exception, $stackTrace'); } try { if (index < totalAssets.value && index >= 0) { - log.info('Precaching next image at index $index'); final asset = loadAsset(index); await precacheImage( ImmichImage.imageProvider(asset: asset), @@ -108,15 +107,13 @@ class GalleryViewerPage extends HookConsumerWidget { } } catch (e) { // swallow error silently - debugPrint('Error precaching next image: $e'); + log.severe('Error precaching next image: $e'); context.maybePop(); } } // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page - ref.listen(currentAssetProvider, (prev, cur) { - log.info('Current asset changed from ${prev?.id} to ${cur?.id}'); - }); + ref.listen(currentAssetProvider, (prev, cur) {}); useEffect(() { ref.read(currentAssetProvider.notifier).set(asset); @@ -338,7 +335,6 @@ class GalleryViewerPage extends HookConsumerWidget { if (newAsset.isImage && !isPlayingMotionVideo.value) { return buildImage(context, newAsset); } - log.info('Loading asset ${newAsset.id} (index $index) as video'); return buildVideo(context, newAsset); } @@ -386,7 +382,6 @@ class GalleryViewerPage extends HookConsumerWidget { itemCount: totalAssets.value, scrollDirection: Axis.horizontal, onPageChanged: (value) { - log.info('Page changed to $value'); final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index de2fad12a3103..902766fa01086 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -24,7 +24,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { final bool showControls; final Duration hideControlsTimer; final Widget placeholder; - // final ValueNotifier? doInitialize; const NativeVideoViewerPage({ super.key, @@ -48,7 +47,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { final isCurrent = currentAsset.value == asset; final log = Logger('NativeVideoViewerPage'); - log.info('Building NativeVideoViewerPage'); final localEntity = useMemoized(() { if (!asset.isLocal) { @@ -60,11 +58,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { Future calculateAspectRatio() async { if (!context.mounted) { - log.info('calculateAspectRatio: Context is not mounted'); return null; } - log.info('Calculating aspect ratio'); late final double? orientatedWidth; late final double? orientatedHeight; @@ -81,7 +77,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { orientatedHeight = entity.orientatedHeight?.toDouble(); } - log.info('Calculated aspect ratio'); if (orientatedWidth != null && orientatedHeight != null && orientatedWidth > 0 && @@ -94,13 +89,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { Future createSource() async { if (!context.mounted) { - log.info('createSource: Context is not mounted'); return null; } if (localEntity != null && asset.livePhotoVideoId == null) { - log.info('Loading video from local storage'); - final file = await (await localEntity)!.file; if (file == null) { throw Exception('No file found for the video'); @@ -110,12 +102,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { path: file.path, type: VideoSourceType.file, ); - log.info('Loaded video from local storage'); return source; } - log.info('Loading video from server'); - // Use a network URL for the video player controller final serverEndpoint = Store.get(StoreKey.serverEndpoint); final String videoUrl = asset.livePhotoVideoId != null @@ -127,7 +116,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { type: VideoSourceType.network, headers: ApiService.getRequestHeaders(), ); - log.info('Loaded video from server'); return source; } @@ -136,14 +124,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { useMemoized( () async { if (!context.mounted) { - log.info('combined: Context is not mounted'); return null; } final (videoSourceRes, aspectRatioRes) = await (createSource(), calculateAspectRatio()).wait; if (videoSourceRes == null || aspectRatioRes == null) { - log.info('combined: Video source or aspect ratio is null'); return; } @@ -162,12 +148,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - log.info('Checking if buffering'); final videoPlayback = ref.read(videoPlaybackValueProvider); if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && videoPlayback.state != VideoPlaybackState.buffering) { - log.info('Marking video as buffering'); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(state: VideoPlaybackState.buffering); } @@ -237,10 +221,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { try { if (pause) { - log.info('Pausing video'); videoController.pause(); } else { - log.info('Playing video'); videoController.play(); } } catch (error) { @@ -254,8 +236,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - log.info('Playback ready for video ${asset.id}'); - try { videoController.play(); videoController.setVolume(0.9); @@ -265,18 +245,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { } ref.listen(currentAssetProvider, (_, value) { - log.info( - 'Changing currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}', - ); // Delay the video playback to avoid a stutter in the swipe animation Timer(const Duration(milliseconds: 350), () { if (!context.mounted) { return; } - log.info( - 'Changed currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}', - ); currentAsset.value = value; if (currentAsset.value == asset) { onPlaybackReady(); @@ -300,11 +274,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (videoPlayback.state == VideoPlaybackState.playing) { // Sync with the controls playing WakelockPlus.enable(); - log.info('Video ${asset.id} is playing; enabled wakelock'); } else { // Sync with the controls pause WakelockPlus.disable(); - log.info('Video ${asset.id} is not playing; disabled wakelock'); } } @@ -350,10 +322,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { } void initController(NativeVideoPlayerController nc) { - log.info('initController for ${asset.id} started'); if (controller.value != null) { - log.info( - 'initController for ${asset.id}: Controller already initialized'); return; } ref.read(videoPlayerControlsProvider.notifier).reset(); @@ -366,7 +335,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { nc.loadVideoSource(videoSource.value!); - log.info('initController for ${asset.id}: setting controller'); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); } @@ -374,10 +342,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { useEffect( () { return () { - log.info('Cleaning up video ${asset.id}'); final playerController = controller.value; if (playerController == null) { - log.info('Controller is null'); return; } From 625b0bbafacefa2743c24d0ba4555241a3f4bd14 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:45:09 -0500 Subject: [PATCH 11/54] refactor aspect ratio calculation --- .../common/native_video_viewer.page.dart | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 902766fa01086..95ed1e2948bc9 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,7 +17,6 @@ import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { @@ -53,7 +53,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { return null; } - return AssetEntity.fromId(asset.localId!); + final local = asset.local; + if (local == null || local.orientation > 0) { + return Future.value(local); + } + + return local.obtainForNewProperties(); }); Future calculateAspectRatio() async { @@ -67,11 +72,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (asset.exifInfo != null) { orientatedWidth = asset.orientatedWidth?.toDouble(); orientatedHeight = asset.orientatedHeight?.toDouble(); - } else if (localEntity != null) { + } + + if (orientatedWidth == null && localEntity != null) { final entity = await localEntity; - orientatedWidth = entity?.orientatedWidth.toDouble(); - orientatedHeight = entity?.orientatedHeight.toDouble(); - } else { + if (entity != null) { + asset.local = entity; + orientatedWidth = entity.orientatedWidth.toDouble(); + orientatedHeight = entity.orientatedHeight.toDouble(); + } + } + + if (orientatedWidth == null) { final entity = await ref.read(assetServiceProvider).loadExif(asset); orientatedWidth = entity.orientatedWidth?.toDouble(); orientatedHeight = entity.orientatedHeight?.toDouble(); From 6a6e97324b327d7b51cfaa0aebe45c7a4dc1696b Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:48:25 -0500 Subject: [PATCH 12/54] removed unnecessary import --- mobile/lib/pages/common/native_video_viewer.page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 95ed1e2948bc9..173af8f5abcd1 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; From d50b8892de372aecf59ca4bc84116ac1347664f2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Nov 2024 17:08:48 -0600 Subject: [PATCH 13/54] transitive dependencies --- mobile/pubspec.lock | 20 ++++++++++---------- mobile/pubspec.yaml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 79737f6a73893..fe93e55a82974 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -378,10 +378,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091 + sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95 url: "https://pub.dev" source: hosted - version: "11.0.0" + version: "11.1.1" device_info_plus_platform_interface: dependency: transitive description: @@ -450,10 +450,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" + sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.1.3" file_selector_linux: dependency: transitive description: @@ -548,10 +548,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 url: "https://pub.dev" source: hosted - version: "17.2.4" + version: "17.2.1+2" flutter_local_notifications_linux: dependency: transitive description: @@ -1076,10 +1076,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef" + sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "8.1.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1348,10 +1348,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11 + sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" url: "https://pub.dev" source: hosted - version: "10.0.3" + version: "10.1.2" share_plus_platform_interface: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 35d986c26090d..ea16c9435f0aa 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -45,7 +45,7 @@ dependencies: path_provider: ^2.1.2 collection: ^1.18.0 http_parser: ^4.0.2 - flutter_web_auth: ^0.6.0 + flutter_web_auth: 0.6.0 easy_image_viewer: ^1.4.0 isar: version: *isar_version From 0a142ea80ea22450fd3c2a99b1311c627b6773ab Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:12:34 -0500 Subject: [PATCH 14/54] fixed referencing uninitialized orientation --- .../lib/pages/common/native_video_viewer.page.dart | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 173af8f5abcd1..3974b1d175992 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/utils/hooks/interval_hook.dart'; +import 'package:immich_mobile/utils/throttle.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -71,18 +72,14 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (asset.exifInfo != null) { orientatedWidth = asset.orientatedWidth?.toDouble(); orientatedHeight = asset.orientatedHeight?.toDouble(); - } - - if (orientatedWidth == null && localEntity != null) { + } else if (localEntity != null) { final entity = await localEntity; if (entity != null) { asset.local = entity; orientatedWidth = entity.orientatedWidth.toDouble(); orientatedHeight = entity.orientatedHeight.toDouble(); } - } - - if (orientatedWidth == null) { + } else { final entity = await ref.read(assetServiceProvider).loadExif(asset); orientatedWidth = entity.orientatedWidth?.toDouble(); orientatedHeight = entity.orientatedHeight?.toDouble(); @@ -196,6 +193,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { }); // When the position changes, seek to the position + final seekThrottler = + useThrottler(interval: const Duration(milliseconds: 200)); ref.listen(videoPlayerControlsProvider.select((value) => value.position), (_, position) { final playerController = controller.value; @@ -212,7 +211,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { final int seek = (asset.duration * (position / 100.0)).inSeconds; if (seek != playbackInfo.position) { try { - playerController.seekTo(seek); + seekThrottler.run(() => playerController.seekTo(seek)); } catch (error) { log.severe('Error seeking to position $position: $error'); } From 49af664c91e7d2447c59210a4f39c21c584cda1a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Nov 2024 21:33:30 -0600 Subject: [PATCH 15/54] use correct ref to build android --- mobile/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index fe93e55a82974..e1da21ad25018 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1029,7 +1029,7 @@ packages: description: path: "." ref: "feat/headers" - resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed" + resolved-ref: "35398174bb73ec62ee3a3fb81f4e4d5728d09ec0" url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" From bd69a94d3705da8b016237e6c07ff73b8cf98617 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:19:12 -0500 Subject: [PATCH 16/54] higher res placeholder for local videos --- .../lib/pages/common/gallery_viewer.page.dart | 17 +++-- mobile/lib/pages/photos/memory.page.dart | 4 + .../image/immich_local_image_provider.dart | 76 +++++++++++++------ mobile/lib/widgets/common/immich_image.dart | 9 ++- mobile/lib/widgets/memories/memory_card.dart | 2 + 5 files changed, 73 insertions(+), 35 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 73cb0cf4f854b..00d0994689836 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -100,7 +100,11 @@ class GalleryViewerPage extends HookConsumerWidget { if (index < totalAssets.value && index >= 0) { final asset = loadAsset(index); await precacheImage( - ImmichImage.imageProvider(asset: asset), + ImmichImage.imageProvider( + asset: asset, + width: context.width, + height: context.height, + ), context, onError: onError, ); @@ -313,7 +317,11 @@ class GalleryViewerPage extends HookConsumerWidget { asset: asset, placeholder: Image( key: ValueKey(asset), - image: ImmichImage.imageProvider(asset: asset), + image: ImmichImage.imageProvider( + asset: asset, + width: context.width, + height: context.height, + ), fit: BoxFit.contain, height: context.height, width: context.width, @@ -396,12 +404,7 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex.value = -1; isPlayingMotionVideo.value = false; - // Delay setting the new asset to avoid a stutter in the page change animation - // TODO: make the scroll animation finish more quickly, and ideally have a callback for when it's done ref.read(currentAssetProvider.notifier).set(newAsset); - // Timer(const Duration(milliseconds: 450), () { - // ref.read(currentAssetProvider.notifier).set(newAsset); - // }); // Wait for page change animation to finish, then precache the next image Timer(const Duration(milliseconds: 400), () { diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 3f86f5be082c0..74a94ed6ee084 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget { } // Precache the asset + final size = MediaQuery.sizeOf(context); await precacheImage( ImmichImage.imageProvider( asset: asset, + width: size.width, + height: size.height, ), context, + size: size, ); } diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index bbfaf12a4f445..36fd3334b9442 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset class ImmichLocalImageProvider extends ImageProvider { final Asset asset; + // only used for videos + final double width; + final double height; + final Logger log = Logger('ImmichLocalImageProvider'); ImmichLocalImageProvider({ required this.asset, + required this.width, + required this.height, }) : assert(asset.local != null, 'Only usable when asset.local is set'); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -42,38 +49,57 @@ class ImmichLocalImageProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - Asset key, + Asset asset, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { - // Load a small thumbnail - final thumbBytes = await asset.local?.thumbnailDataWithSize( - const ThumbnailSize.square(256), - quality: 80, - ); - if (thumbBytes != null) { - final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); - final codec = await decode(buffer); - yield codec; - } else { - debugPrint("Loading thumb for ${asset.fileName} failed"); - } + ui.ImmutableBuffer? buffer; + try { + final local = asset.local; + if (local == null) { + throw StateError('Asset ${asset.fileName} has no local data'); + } - if (asset.isImage) { - final File? file = await asset.local?.originFile; - if (file == null) { - throw StateError("Opening file for asset ${asset.fileName} failed"); + var thumbBytes = await local + .thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80); + if (thumbBytes == null) { + throw StateError("Loading thumbnail for ${asset.fileName} failed"); } - try { - final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); - final codec = await decode(buffer); - yield codec; - } catch (error) { - throw StateError("Loading asset ${asset.fileName} failed"); + buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + thumbBytes = null; + yield await decode(buffer); + buffer = null; + + switch (asset.type) { + case AssetType.image: + final File? file = await local.originFile; + if (file == null) { + throw StateError("Opening file for asset ${asset.fileName} failed"); + } + buffer = await ui.ImmutableBuffer.fromFilePath(file.path); + yield await decode(buffer); + buffer = null; + break; + case AssetType.video: + final size = ThumbnailSize(width.ceil(), height.ceil()); + thumbBytes = await local.thumbnailDataWithSize(size); + if (thumbBytes == null) { + throw StateError("Failed to load preview for ${asset.fileName}"); + } + buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + thumbBytes = null; + yield await decode(buffer); + buffer = null; + break; + default: + throw StateError('Unsupported asset type ${asset.type}'); } + } catch (error, stack) { + log.severe('Error loading local image ${asset.fileName}', error, stack); + buffer?.dispose(); + } finally { + chunkEvents.close(); } - - chunkEvents.close(); } @override diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index 5946dee453ad5..ab0f2584b554c 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget { // either by using the asset ID or the asset itself /// [asset] is the Asset to request, or else use [assetId] to get a remote /// image provider - /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail - /// The size of the square thumbnail to request. Ignored if isThumbnail - /// is not true static ImageProvider imageProvider({ Asset? asset, String? assetId, + double width = 1080, + double height = 1920, }) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); @@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget { if (useLocal(asset)) { return ImmichLocalImageProvider( asset: asset, + width: width, + height: height, ); } else { return ImmichRemoteImageProvider( @@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget { }, image: ImmichImage.imageProvider( asset: asset, + width: context.width, + height: context.height, ), width: width, height: height, diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index de331670abf2e..ba74b0496dca2 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -136,6 +136,8 @@ class _BlurredBackdrop extends HookWidget { image: DecorationImage( image: ImmichImage.imageProvider( asset: asset, + height: context.height, + width: context.width, ), fit: BoxFit.cover, ), From 59434b6496910dc2890b6b4f9ba902fc86ca61a2 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:21:56 -0500 Subject: [PATCH 17/54] slightly lower delay --- mobile/lib/pages/common/native_video_viewer.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 3974b1d175992..4ab63f4973f4e 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -256,7 +256,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { ref.listen(currentAssetProvider, (_, value) { // Delay the video playback to avoid a stutter in the swipe animation - Timer(const Duration(milliseconds: 350), () { + Timer(const Duration(milliseconds: 300), () { if (!context.mounted) { return; } From 6a8ae25d0080ab89eefe82d475e2ab8b0d91852e Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:04:43 -0500 Subject: [PATCH 18/54] await things --- .../common/native_video_viewer.page.dart | 85 ++++++++++--------- mobile/lib/utils/throttle.dart | 5 +- 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 4ab63f4973f4e..e9ee79275daaf 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -170,7 +170,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { // When the volume changes, set the volume ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, mute) { + (_, mute) async { final playerController = controller.value; if (playerController == null) { return; @@ -183,9 +183,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { try { if (mute && playbackInfo.volume != 0.0) { - playerController.setVolume(0.0); + await playerController.setVolume(0.0); } else if (!mute && playbackInfo.volume != 0.7) { - playerController.setVolume(0.7); + await playerController.setVolume(0.7); } } catch (error) { log.severe('Error setting volume: $error'); @@ -196,7 +196,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { final seekThrottler = useThrottler(interval: const Duration(milliseconds: 200)); ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { + (_, position) async { final playerController = controller.value; if (playerController == null) { return; @@ -211,7 +211,11 @@ class NativeVideoViewerPage extends HookConsumerWidget { final int seek = (asset.duration * (position / 100.0)).inSeconds; if (seek != playbackInfo.position) { try { - seekThrottler.run(() => playerController.seekTo(seek)); + final maybeSeek = + seekThrottler.run(() => playerController.seekTo(seek)); + if (maybeSeek != null) { + await maybeSeek; + } } catch (error) { log.severe('Error seeking to position $position: $error'); } @@ -223,7 +227,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { // // When the custom video controls pause or play ref.listen(videoPlayerControlsProvider.select((value) => value.pause), - (_, pause) { + (_, pause) async { final videoController = controller.value; if (videoController == null || !context.mounted) { return; @@ -231,43 +235,29 @@ class NativeVideoViewerPage extends HookConsumerWidget { try { if (pause) { - videoController.pause(); + await videoController.pause(); } else { - videoController.play(); + await videoController.play(); } } catch (error) { log.severe('Error pausing or playing video: $error'); } }); - void onPlaybackReady() { + void onPlaybackReady() async { final videoController = controller.value; if (videoController == null || !isCurrent || !context.mounted) { return; } try { - videoController.play(); - videoController.setVolume(0.9); + await videoController.play(); + await videoController.setVolume(0.9); } catch (error) { log.severe('Error playing video: $error'); } } - ref.listen(currentAssetProvider, (_, value) { - // Delay the video playback to avoid a stutter in the swipe animation - Timer(const Duration(milliseconds: 300), () { - if (!context.mounted) { - return; - } - - currentAsset.value = value; - if (currentAsset.value == asset) { - onPlaybackReady(); - } - }); - }); - void onPlaybackStatusChanged() { final videoController = controller.value; if (videoController == null || !context.mounted) { @@ -331,6 +321,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { } } + void removeListeners(NativeVideoPlayerController controller) { + controller.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + controller.onPlaybackStatusChanged + .removeListener(onPlaybackStatusChanged); + controller.onPlaybackReady.removeListener(onPlaybackReady); + controller.onPlaybackEnded.removeListener(onPlaybackEnded); + } + void initController(NativeVideoPlayerController nc) { if (controller.value != null) { return; @@ -349,6 +348,25 @@ class NativeVideoViewerPage extends HookConsumerWidget { Timer(const Duration(milliseconds: 200), checkIfBuffering); } + ref.listen(currentAssetProvider, (_, value) { + final playerController = controller.value; + if (playerController != null && value != asset) { + removeListeners(playerController); + } + + // Delay the video playback to avoid a stutter in the swipe animation + Timer(const Duration(milliseconds: 300), () { + if (!context.mounted) { + return; + } + + currentAsset.value = value; + if (currentAsset.value == asset) { + onPlaybackReady(); + } + }); + }); + useEffect( () { return () { @@ -356,19 +374,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (playerController == null) { return; } - - try { - playerController.stop(); - - playerController.onPlaybackPositionChanged - .removeListener(onPlaybackPositionChanged); - playerController.onPlaybackStatusChanged - .removeListener(onPlaybackStatusChanged); - playerController.onPlaybackReady.removeListener(onPlaybackReady); - playerController.onPlaybackEnded.removeListener(onPlaybackEnded); - } catch (error) { - log.severe('Error during useEffect cleanup: $error'); - } + removeListeners(playerController); + playerController.stop().catchError((error) { + log.severe('Error stopping video: $error'); + }); controller.value = null; WakelockPlus.disable(); diff --git a/mobile/lib/utils/throttle.dart b/mobile/lib/utils/throttle.dart index 9a54e01fc195c..3aa907e19fdef 100644 --- a/mobile/lib/utils/throttle.dart +++ b/mobile/lib/utils/throttle.dart @@ -10,11 +10,12 @@ class Throttler { Throttler({required this.interval}); - void run(FutureOr Function() action) { + T? run(T Function() action) { if (_lastActionTime == null || (DateTime.now().difference(_lastActionTime!) > interval)) { - action(); + final response = action(); _lastActionTime = DateTime.now(); + return response; } } From b3e7b3a4c84438edf87fffc50776d226056a179d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 9 Nov 2024 00:31:10 -0500 Subject: [PATCH 19/54] fix controls when swiping between image and video --- mobile/lib/entities/asset.entity.dart | 3 ++ .../lib/pages/common/gallery_viewer.page.dart | 43 ++++++++----------- .../common/native_video_viewer.page.dart | 4 +- .../asset_viewer/bottom_gallery_bar.dart | 14 +++--- .../custom_video_player_controls.dart | 9 +++- .../widgets/asset_viewer/gallery_app_bar.dart | 23 +++++----- 6 files changed, 51 insertions(+), 45 deletions(-) diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 2f04f61c5a6b9..24b88701cca08 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -168,6 +168,9 @@ class Asset { @ignore bool get isImage => type == AssetType.image; + @ignore + bool get isVideo => type == AssetType.video; + @ignore bool get isMotionPhoto => livePhotoVideoId != null; diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 00d0994689836..7012bfc0cced9 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -79,10 +79,8 @@ class GalleryViewerPage extends HookConsumerWidget { ref.listen( videoPlaybackValueProvider.select( (playback) => playback.state == VideoPlaybackState.playing, - ), (wasPlaying, isPlaying) { - if (wasPlaying != null && wasPlaying && !isPlaying) { - isPlayingMotionVideo.value = false; - } + ), (_, isPlaying) { + isPlayingMotionVideo.value = isPlaying; }); } @@ -271,11 +269,13 @@ class GalleryViewerPage extends HookConsumerWidget { onTapDown: (_, __, ___) { ref.read(showControlsProvider.notifier).toggle(); }, - onLongPressStart: (_, __, ___) { - if (asset.livePhotoVideoId != null) { - isPlayingMotionVideo.value = true; - } - }, + onLongPressStart: asset.isMotionPhoto + ? (_, __, ___) { + if (asset.isMotionPhoto) { + isPlayingMotionVideo.value = true; + } + } + : null, imageProvider: ImmichImage.imageProvider(asset: asset), heroAttributes: PhotoViewHeroAttributes( tag: getHeroTag(asset), @@ -336,10 +336,6 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { final newAsset = index == currentIndex.value ? asset : loadAsset(index); - if (newAsset.isImage) { - ref.read(showControlsProvider.notifier).show = false; - } - if (newAsset.isImage && !isPlayingMotionVideo.value) { return buildImage(context, newAsset); } @@ -357,8 +353,11 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGallery.builder( key: ValueKey(asset), scaleStateChangedCallback: (state) { - isZoomed.value = state != PhotoViewScaleState.initial; - ref.read(showControlsProvider.notifier).show = !isZoomed.value; + if (asset.isImage && !isPlayingMotionVideo.value) { + isZoomed.value = state != PhotoViewScaleState.initial; + ref.read(showControlsProvider.notifier).show = + !isZoomed.value; + } }, // wantKeepAlive: true, gaplessPlayback: true, @@ -396,15 +395,15 @@ class GalleryViewerPage extends HookConsumerWidget { final newAsset = value == currentIndex.value ? asset : loadAsset(value); - if (!newAsset.isImage || newAsset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } currentIndex.value = value; stackIndex.value = -1; isPlayingMotionVideo.value = false; ref.read(currentAssetProvider.notifier).set(newAsset); + if (newAsset.isVideo || newAsset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } // Wait for page change animation to finish, then precache the next image Timer(const Duration(milliseconds: 400), () { @@ -418,11 +417,8 @@ class GalleryViewerPage extends HookConsumerWidget { left: 0, right: 0, child: GalleryAppBar( - asset: asset, showInfo: showInfo, - isPlayingVideo: isPlayingMotionVideo.value, - onToggleMotionVideo: () => - isPlayingMotionVideo.value = !isPlayingMotionVideo.value, + isPlayingMotionVideo: isPlayingMotionVideo, ), ), Positioned( @@ -444,10 +440,7 @@ class GalleryViewerPage extends HookConsumerWidget { controller: controller, showStack: showStack, stackIndex: stackIndex.value, - asset: asset, assetIndex: currentIndex, - showVideoPlayerControls: - !asset.isImage && !asset.isMotionPhoto, ), ], ), diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index e9ee79275daaf..663f4e0774ad1 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -184,8 +184,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { try { if (mute && playbackInfo.volume != 0.0) { await playerController.setVolume(0.0); - } else if (!mute && playbackInfo.volume != 0.7) { - await playerController.setVolume(0.7); + } else if (!mute && playbackInfo.volume != 0.9) { + await playerController.setVolume(0.9); } } catch (error) { log.severe('Error setting volume: $error'); diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index f698e866ad8da..912d8ac449322 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; @@ -26,12 +27,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart'; class BottomGalleryBar extends ConsumerWidget { - final Asset asset; final ValueNotifier assetIndex; final bool showStack; final int stackIndex; final ValueNotifier totalAssets; - final bool showVideoPlayerControls; final PageController controller; final RenderList renderList; @@ -39,17 +38,20 @@ class BottomGalleryBar extends ConsumerWidget { super.key, required this.showStack, required this.stackIndex, - required this.asset, required this.assetIndex, required this.controller, required this.totalAssets, - required this.showVideoPlayerControls, required this.renderList, }); @override Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + final showControls = ref.watch(showControlsProvider); final stackItems = showStack && asset.stackCount > 0 ? ref.watch(assetStackStateProvider(asset)) @@ -324,7 +326,7 @@ class BottomGalleryBar extends ConsumerWidget { }, ]; return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), + ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, @@ -341,7 +343,7 @@ class BottomGalleryBar extends ConsumerWidget { padding: EdgeInsets.only(top: 40.0), child: Column( children: [ - if (showVideoPlayerControls) const VideoControls(), + if (asset.isVideo) const VideoControls(), BottomNavigationBar( elevation: 0.0, backgroundColor: Colors.transparent, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 2fd0a38edc537..168c4ebffb1d8 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -17,10 +17,15 @@ class CustomVideoPlayerControls extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final showControls = ref.watch(showControlsProvider); // A timer to hide the controls final hideTimer = useTimer( hideTimerDuration, () { + if (!context.mounted) { + return; + } + final state = ref.read(videoPlaybackValueProvider).state; // Do not hide on paused if (state != VideoPlaybackState.paused) { @@ -66,7 +71,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { behavior: HitTestBehavior.opaque, onTap: showControlsAndStartHideTimer, child: AbsorbPointer( - absorbing: !ref.watch(showControlsProvider), + absorbing: !showControls, child: Stack( children: [ if (showBuffering) @@ -84,7 +89,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { iconColor: Colors.white, isFinished: state == VideoPlaybackState.completed, isPlaying: state == VideoPlaybackState.playing, - show: ref.watch(showControlsProvider), + show: showControls, onPressed: togglePlay, ), ), diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index f400224e0a0be..30cc709452033 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; @@ -19,23 +20,24 @@ import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class GalleryAppBar extends ConsumerWidget { - final Asset asset; final void Function() showInfo; - final void Function() onToggleMotionVideo; - final bool isPlayingVideo; + final ValueNotifier isPlayingMotionVideo; const GalleryAppBar({ super.key, - required this.asset, required this.showInfo, - required this.onToggleMotionVideo, - required this.isPlayingVideo, + required this.isPlayingMotionVideo, }); @override Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } final album = ref.watch(currentAlbumProvider); final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + final showControls = ref.watch(showControlsProvider); final isPartner = ref .watch(partnerSharedWithProvider) @@ -98,23 +100,24 @@ class GalleryAppBar extends ConsumerWidget { } return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), + ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + opacity: showControls ? 1.0 : 0.0, child: Container( color: Colors.black.withOpacity(0.4), child: TopControlAppBar( isOwner: isOwner, isPartner: isPartner, - isPlayingMotionVideo: isPlayingVideo, + isPlayingMotionVideo: isPlayingMotionVideo.value, asset: asset, onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, onDownloadPressed: asset.isLocal ? null : handleDownloadAsset, - onToggleMotionVideo: onToggleMotionVideo, + onToggleMotionVideo: () => + isPlayingMotionVideo.value = !isPlayingMotionVideo.value, onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, ), From a91d261cea80e724597aec39dd04957e36a80732 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 9 Nov 2024 00:38:07 -0500 Subject: [PATCH 20/54] linting --- mobile/lib/utils/throttle.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/utils/throttle.dart b/mobile/lib/utils/throttle.dart index 3aa907e19fdef..bc0dcf9e2fe2a 100644 --- a/mobile/lib/utils/throttle.dart +++ b/mobile/lib/utils/throttle.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter_hooks/flutter_hooks.dart'; /// Throttles function calls with the [interval] provided. @@ -17,6 +15,8 @@ class Throttler { _lastActionTime = DateTime.now(); return response; } + + return null; } void dispose() { From 3e0e813b58d80d4bb65f934fcfa1417666d73e33 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:32:08 -0500 Subject: [PATCH 21/54] extra smooth seeking, add comments --- .../lib/pages/common/gallery_viewer.page.dart | 1 + .../common/native_video_viewer.page.dart | 53 ++++++++++------- .../video_player_value_provider.dart | 10 ++++ mobile/lib/utils/debounce.dart | 57 +++++++++++++++++-- .../widgets/asset_viewer/video_position.dart | 8 ++- 5 files changed, 104 insertions(+), 25 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 7012bfc0cced9..069ff1ef64cac 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -292,6 +292,7 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { + // This key is to prevent the video player from being re-initialized during the hero animation final key = GlobalKey(); final tag = getHeroTag(asset); return PhotoViewGalleryPageOptions.customChild( diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 663f4e0774ad1..73ae77dabc001 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -12,8 +12,8 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; +import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/hooks/interval_hook.dart'; -import 'package:immich_mobile/utils/throttle.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -43,6 +43,11 @@ class NativeVideoViewerPage extends HookConsumerWidget { final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); + + // When a video is opened through the timeline, `isCurrent` will immediately be true. + // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. + // If the swipe is completed, `isCurrent` will be true for video B after a delay. + // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; @@ -193,8 +198,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { }); // When the position changes, seek to the position - final seekThrottler = - useThrottler(interval: const Duration(milliseconds: 200)); + // Debounce the seek to avoid seeking too often + // But also don't delay the seek too much to maintain visual feedback + final seekDebouncer = useDebouncer( + interval: const Duration(milliseconds: 100), + maxWaitTime: const Duration(milliseconds: 200), + ); ref.listen(videoPlayerControlsProvider.select((value) => value.position), (_, position) async { final playerController = controller.value; @@ -208,21 +217,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Find the position to seek to - final int seek = (asset.duration * (position / 100.0)).inSeconds; + final seek = position ~/ 1; if (seek != playbackInfo.position) { - try { - final maybeSeek = - seekThrottler.run(() => playerController.seekTo(seek)); - if (maybeSeek != null) { - await maybeSeek; - } - } catch (error) { - log.severe('Error seeking to position $position: $error'); - } + seekDebouncer.run(() => playerController.seekTo(seek)); } - - ref.read(videoPlaybackValueProvider.notifier).position = - Duration(seconds: seek); }); // // When the custom video controls pause or play @@ -233,6 +231,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + try { if (pause) { await videoController.pause(); @@ -250,6 +254,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } + final videoPlayback = + VideoPlaybackValue.fromNativeController(videoController); + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + try { await videoController.play(); await videoController.setVolume(0.9); @@ -266,11 +274,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); + // No need to update the UI when it's about to loop if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) { return; } - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - + ref.read(videoPlaybackValueProvider.notifier).status = + videoPlayback.state; if (videoPlayback.state == VideoPlaybackState.playing) { // Sync with the controls playing WakelockPlus.enable(); @@ -281,6 +290,11 @@ class NativeVideoViewerPage extends HookConsumerWidget { } void onPlaybackPositionChanged() { + // When seeking, these events sometimes move the slider to an older position + if (seekDebouncer.isActive) { + return; + } + final videoController = controller.value; if (videoController == null || !context.mounted) { return; @@ -388,7 +402,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { return Stack( children: [ - placeholder, + placeholder, // this is always under the video to avoid flickering Center( key: ValueKey('player-${asset.hashCode}'), child: aspectRatio.value != null @@ -404,7 +418,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { ) : null, ), - // covers the video with the placeholder if (showControls) Center( key: ValueKey('controls-${asset.hashCode}'), diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index b4f690fa22f0f..3c9ac0b99a6c5 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -125,6 +125,16 @@ class VideoPlaybackValueState extends StateNotifier { ); } + set status(VideoPlaybackState value) { + if (state.state == value) return; + state = VideoPlaybackValue( + position: state.position, + duration: state.duration, + state: value, + volume: state.volume, + ); + } + void reset() { state = videoPlaybackValueDefault; } diff --git a/mobile/lib/utils/debounce.dart b/mobile/lib/utils/debounce.dart index ca5f8fc2beef0..78870151a6e78 100644 --- a/mobile/lib/utils/debounce.dart +++ b/mobile/lib/utils/debounce.dart @@ -3,20 +3,52 @@ import 'dart:async'; import 'package:flutter_hooks/flutter_hooks.dart'; /// Used to debounce function calls with the [interval] provided. +/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied. class Debouncer { - Debouncer({required this.interval}); + Debouncer({required this.interval, this.maxWaitTime}); final Duration interval; + final Duration? maxWaitTime; Timer? _timer; FutureOr Function()? _lastAction; + DateTime? _lastActionTime; + Future? _actionFuture; void run(FutureOr Function() action) { _lastAction = action; _timer?.cancel(); + + if (maxWaitTime != null && + // _actionFuture == null && // TODO: should this check be here? + (_lastActionTime == null || + DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) { + _callAndRest(); + return; + } _timer = Timer(interval, _callAndRest); } + Future? drain() { + if (_timer != null && _timer!.isActive) { + _timer!.cancel(); + if (_lastAction != null) { + _callAndRest(); + } + } + return _actionFuture; + } + + @pragma('vm:prefer-inline') void _callAndRest() { - _lastAction?.call(); + _lastActionTime = DateTime.now(); + final action = _lastAction; + _lastAction = null; + + final result = action!(); + if (result is Future) { + _actionFuture = result.whenComplete(() { + _actionFuture = null; + }); + } _timer = null; } @@ -24,31 +56,48 @@ class Debouncer { _timer?.cancel(); _timer = null; _lastAction = null; + _lastActionTime = null; + _actionFuture = null; } + + bool get isActive => + _actionFuture != null || (_timer != null && _timer!.isActive); } /// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a /// default interval of 300ms is used to debounce the function calls Debouncer useDebouncer({ Duration interval = const Duration(milliseconds: 300), + Duration? maxWaitTime, List? keys, }) => - use(_DebouncerHook(interval: interval, keys: keys)); + use( + _DebouncerHook( + interval: interval, + maxWaitTime: maxWaitTime, + keys: keys, + ), + ); class _DebouncerHook extends Hook { const _DebouncerHook({ required this.interval, + this.maxWaitTime, super.keys, }); final Duration interval; + final Duration? maxWaitTime; @override HookState> createState() => _DebouncerHookState(); } class _DebouncerHookState extends HookState { - late final debouncer = Debouncer(interval: hook.interval); + late final debouncer = Debouncer( + interval: hook.interval, + maxWaitTime: hook.maxWaitTime, + ); @override Debouncer build(_) => debouncer; diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index ef309b9c8561a..b1f70b868685a 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -56,10 +56,16 @@ class VideoPosition extends HookConsumerWidget { ref.read(videoPlayerControlsProvider.notifier).play(); } }, - onChanged: (position) { + onChanged: (value) { + final inSeconds = + (duration * (value / 100.0)).inSeconds; + final position = inSeconds.toDouble(); ref .read(videoPlayerControlsProvider.notifier) .position = position; + // This immediately updates the slider position without waiting for the video to update + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: inSeconds); }, ), ), From 8599ec22a1de7d5599870b5fa9ad926434b25739 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 14 Nov 2024 10:07:12 -0600 Subject: [PATCH 22/54] chore: generate router page --- .../common/native_video_viewer.page.dart | 2 + mobile/lib/routing/router.dart | 5 ++ mobile/lib/routing/router.gr.dart | 64 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 73ae77dabc001..290f8d7f2ebb5 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -19,6 +20,7 @@ import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +@RoutePage() class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; final bool showControls; diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b001c6bdd6d33..785d23a7ad83e 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/library/local_albums.page.dart'; import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; @@ -272,6 +273,10 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), + AutoRoute( + page: NativeVideoViewerRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index ea7d385e85626..49da6fcd9eb6b 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1079,6 +1079,70 @@ class MemoryRouteArgs { } } +/// generated route for +/// [NativeVideoViewerPage] +class NativeVideoViewerRoute extends PageRouteInfo { + NativeVideoViewerRoute({ + Key? key, + required Asset asset, + required Widget placeholder, + bool showControls = true, + Duration hideControlsTimer = const Duration(seconds: 5), + List? children, + }) : super( + NativeVideoViewerRoute.name, + args: NativeVideoViewerRouteArgs( + key: key, + asset: asset, + placeholder: placeholder, + showControls: showControls, + hideControlsTimer: hideControlsTimer, + ), + initialChildren: children, + ); + + static const String name = 'NativeVideoViewerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return NativeVideoViewerPage( + key: args.key, + asset: args.asset, + placeholder: args.placeholder, + showControls: args.showControls, + hideControlsTimer: args.hideControlsTimer, + ); + }, + ); +} + +class NativeVideoViewerRouteArgs { + const NativeVideoViewerRouteArgs({ + this.key, + required this.asset, + required this.placeholder, + this.showControls = true, + this.hideControlsTimer = const Duration(seconds: 5), + }); + + final Key? key; + + final Asset asset; + + final Widget placeholder; + + final bool showControls; + + final Duration hideControlsTimer; + + @override + String toString() { + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer}'; + } +} + /// generated route for /// [PartnerDetailPage] class PartnerDetailRoute extends PageRouteInfo { From ed3152f63dafebe8c77493be465969d9e4d2bbf0 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:36:34 -0500 Subject: [PATCH 23/54] use current asset provider and loadAsset --- .../lib/pages/common/gallery_viewer.page.dart | 171 +++++++++--------- .../common/native_video_viewer.page.dart | 48 ++--- mobile/lib/routing/router.gr.dart | 8 +- .../asset_grid/immich_asset_grid_view.dart | 32 +++- .../custom_video_player_controls.dart | 10 +- mobile/lib/widgets/memories/memory_card.dart | 1 - 6 files changed, 145 insertions(+), 125 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 069ff1ef64cac..7355ea97e1e0d 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -67,22 +67,17 @@ class GalleryViewerPage extends HookConsumerWidget { ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; - // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == noDbId; - - Asset asset = stackIndex.value == -1 - ? currentAsset - : stackElements.elementAt(stackIndex.value); // // Update is playing motion video - if (asset.isMotionPhoto) { - ref.listen( - videoPlaybackValueProvider.select( - (playback) => playback.state == VideoPlaybackState.playing, - ), (_, isPlaying) { + ref.listen( + videoPlaybackValueProvider.select( + (playback) => playback.state == VideoPlaybackState.playing, + ), (_, isPlaying) { + final asset = ref.read(currentAssetProvider); + if (asset != null && asset.isMotionPhoto) { isPlayingMotionVideo.value = isPlaying; - }); - } + } + }); Future precacheNextImage(int index) async { if (!context.mounted) { @@ -114,26 +109,29 @@ class GalleryViewerPage extends HookConsumerWidget { } } - // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page - ref.listen(currentAssetProvider, (prev, cur) {}); - - useEffect(() { - ref.read(currentAssetProvider.notifier).set(asset); - if (ref.read(showControlsProvider)) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } + useEffect( + () { + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } - // Delay this a bit so we can finish loading the page - Timer(const Duration(milliseconds: 400), () { - precacheNextImage(currentIndex.value + 1); - }); + // Delay this a bit so we can finish loading the page + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(currentIndex.value + 1); + }); - return null; - }); + return null; + }, + [], + ); void showInfo() { + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } showModalBottomSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(15.0)), @@ -205,7 +203,6 @@ class GalleryViewerPage extends HookConsumerWidget { } return ListView.builder( - key: ValueKey(currentAsset), shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, @@ -252,12 +249,6 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - Object getHeroTag(Asset asset) { - return isFromDto - ? '${asset.remoteId}-$heroOffset' - : asset.id + heroOffset; - } - PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) { @@ -277,10 +268,7 @@ class GalleryViewerPage extends HookConsumerWidget { } : null, imageProvider: ImmichImage.imageProvider(asset: asset), - heroAttributes: PhotoViewHeroAttributes( - tag: getHeroTag(asset), - transitionOnUserGestures: true, - ), + heroAttributes: _getHeroAttributes(asset), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, @@ -294,40 +282,33 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { // This key is to prevent the video player from being re-initialized during the hero animation final key = GlobalKey(); - final tag = getHeroTag(asset); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), - heroAttributes: PhotoViewHeroAttributes( - tag: tag, - transitionOnUserGestures: true, - ), + heroAttributes: _getHeroAttributes(asset), filterQuality: FilterQuality.high, initialScale: 1.0, maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, - child: Hero( - tag: tag, - child: SizedBox( - width: context.width, - height: context.height, - child: NativeVideoViewerPage( - key: key, - asset: asset, - placeholder: Image( - key: ValueKey(asset), - image: ImmichImage.imageProvider( - asset: asset, - width: context.width, - height: context.height, - ), - fit: BoxFit.contain, - height: context.height, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: key, + asset: asset, + placeholder: Image( + key: ValueKey(asset), + image: ImmichImage.imageProvider( + asset: asset, width: context.width, - alignment: Alignment.center, + height: context.height, ), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, ), ), ), @@ -335,7 +316,7 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { - final newAsset = index == currentIndex.value ? asset : loadAsset(index); + final newAsset = loadAsset(index); if (newAsset.isImage && !isPlayingMotionVideo.value) { return buildImage(context, newAsset); @@ -343,6 +324,8 @@ class GalleryViewerPage extends HookConsumerWidget { return buildVideo(context, newAsset); } + log.info('GalleryViewerPage: Building gallery viewer page'); + return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -352,34 +335,41 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( - key: ValueKey(asset), + key: const ValueKey('gallery'), scaleStateChangedCallback: (state) { + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } + if (asset.isImage && !isPlayingMotionVideo.value) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; } }, - // wantKeepAlive: true, gaplessPlayback: true, - loadingBuilder: (context, event, index) => ClipRect( - child: Stack( - fit: StackFit.expand, - children: [ - BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, + loadingBuilder: (context, event, index) { + final asset = loadAsset(index); + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), ), - ), - ImmichThumbnail( - key: ValueKey(asset), - asset: asset, - fit: BoxFit.contain, - ), - ], - ), - ), + ImmichThumbnail( + key: ValueKey(asset), + asset: asset, + fit: BoxFit.contain, + ), + ], + ), + ); + }, pageController: controller, scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in @@ -394,8 +384,7 @@ class GalleryViewerPage extends HookConsumerWidget { ref.read(hapticFeedbackProvider.notifier).selectionClick(); - final newAsset = - value == currentIndex.value ? asset : loadAsset(value); + final newAsset = loadAsset(value); currentIndex.value = value; stackIndex.value = -1; @@ -418,6 +407,7 @@ class GalleryViewerPage extends HookConsumerWidget { left: 0, right: 0, child: GalleryAppBar( + key: const ValueKey('app-bar'), showInfo: showInfo, isPlayingMotionVideo: isPlayingMotionVideo, ), @@ -436,6 +426,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), BottomGalleryBar( + key: const ValueKey('bottom-bar'), renderList: renderList, totalAssets: totalAssets, controller: controller, @@ -452,4 +443,14 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } + + @pragma('vm:prefer-inline') + PhotoViewHeroAttributes _getHeroAttributes(Asset asset) { + return PhotoViewHeroAttributes( + tag: asset.isInDb + ? asset.id + heroOffset + : '${asset.remoteId}-$heroOffset', + transitionOnUserGestures: true, + ); + } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 290f8d7f2ebb5..9612de8f35aaa 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -24,7 +24,6 @@ import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; final bool showControls; - final Duration hideControlsTimer; final Widget placeholder; const NativeVideoViewerPage({ @@ -32,7 +31,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { required this.asset, required this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), }); @override @@ -370,6 +368,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { removeListeners(playerController); } + final curAsset = currentAsset.value; + if (curAsset == asset) { + return; + } + + // no need to delay video playback when swiping from an image to a video + if (curAsset != null && !curAsset.isVideo) { + currentAsset.value = value; + onPlaybackReady(); + return; + } + // Delay the video playback to avoid a stutter in the swipe animation Timer(const Duration(milliseconds: 300), () { if (!context.mounted) { @@ -395,38 +405,30 @@ class NativeVideoViewerPage extends HookConsumerWidget { log.severe('Error stopping video: $error'); }); - controller.value = null; WakelockPlus.disable(); }; }, - [videoSource], + [], ); return Stack( children: [ placeholder, // this is always under the video to avoid flickering - Center( - key: ValueKey('player-${asset.hashCode}'), - child: aspectRatio.value != null - ? AspectRatio( - key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent - ? NativeVideoPlayerView( - key: ValueKey(asset), - onViewReady: initController, - ) - : null, - ) - : null, - ), - if (showControls) + if (aspectRatio.value != null) Center( - key: ValueKey('controls-${asset.hashCode}'), - child: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, + key: ValueKey(asset), + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, ), ), + if (showControls) const Center(child: CustomVideoPlayerControls()), ], ); } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 49da6fcd9eb6b..5e88d8879abbb 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1087,7 +1087,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { required Asset asset, required Widget placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(seconds: 5), List? children, }) : super( NativeVideoViewerRoute.name, @@ -1096,7 +1095,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: asset, placeholder: placeholder, showControls: showControls, - hideControlsTimer: hideControlsTimer, ), initialChildren: children, ); @@ -1112,7 +1110,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: args.asset, placeholder: args.placeholder, showControls: args.showControls, - hideControlsTimer: args.hideControlsTimer, ); }, ); @@ -1124,7 +1121,6 @@ class NativeVideoViewerRouteArgs { required this.asset, required this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), }); final Key? key; @@ -1135,11 +1131,9 @@ class NativeVideoViewerRouteArgs { final bool showControls; - final Duration hideControlsTimer; - @override String toString() { - return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer}'; + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, placeholder: $placeholder, showControls: $showControls}'; } } diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 38e499b5dec8e..5670aa388f9ee 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -12,7 +12,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; @@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState { ScrollOffsetController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); + late final KeepAliveLink currentAssetLink; /// The timestamp when the haptic feedback was last invoked int _hapticFeedbackTS = 0; @@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState { allAssetsSelected: _allAssetsSelected, showStack: widget.showStack, heroOffset: widget.heroOffset, + onAssetTap: (asset) { + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } + }, ); } @@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState { @override void initState() { super.initState(); + currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive(); scrollToTopNotifierProvider.addListener(_scrollToTop); scrollToDateNotifierProvider.addListener(_scrollToDate); @@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState { _itemPositionsListener.itemPositions.removeListener(_positionListener); } _itemPositionsListener.itemPositions.removeListener(_hapticsListener); + currentAssetLink.close(); super.dispose(); } @@ -595,12 +606,13 @@ class _Section extends StatelessWidget { final RenderList renderList; final bool selectionActive; final bool dynamicLayout; - final Function(List) selectAssets; - final Function(List) deselectAssets; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; final bool Function(List) allAssetsSelected; final bool showStack; final int heroOffset; final bool showStorageIndicator; + final void Function(Asset) onAssetTap; const _Section({ required this.section, @@ -618,6 +630,7 @@ class _Section extends StatelessWidget { required this.showStack, required this.heroOffset, required this.showStorageIndicator, + required this.onAssetTap, }); @override @@ -683,6 +696,7 @@ class _Section extends StatelessWidget { selectionActive: selectionActive, onSelect: (asset) => selectAssets([asset]), onDeselect: (asset) => deselectAssets([asset]), + onAssetTap: onAssetTap, ), ], ); @@ -724,9 +738,9 @@ class _Title extends StatelessWidget { final String title; final List assets; final bool selectionActive; - final Function(List) selectAssets; - final Function(List) deselectAssets; - final Function(List) allAssetsSelected; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; + final bool Function(List) allAssetsSelected; const _Title({ required this.title, @@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget { final bool showStorageIndicator; final int heroOffset; final bool showStack; - final Function(Asset)? onSelect; - final Function(Asset)? onDeselect; + final void Function(Asset) onAssetTap; + final void Function(Asset)? onSelect; + final void Function(Asset)? onDeselect; final bool isSelectionActive; const _AssetRow({ @@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget { required this.showStack, required this.isSelectionActive, required this.selectedAssets, + required this.onAssetTap, this.onSelect, this.onDeselect, }); @@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget { onSelect?.call(asset); } } else { + final asset = renderList.loadAsset(absoluteOffset + index); + onAssetTap(asset); context.pushRoute( GalleryViewerRoute( renderList: renderList, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 168c4ebffb1d8..0df8137417426 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; @@ -12,7 +13,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { const CustomVideoPlayerControls({ super.key, - this.hideTimerDuration = const Duration(seconds: 3), + this.hideTimerDuration = const Duration(seconds: 5), }); @override @@ -28,7 +29,12 @@ class CustomVideoPlayerControls extends HookConsumerWidget { final state = ref.read(videoPlaybackValueProvider).state; // Do not hide on paused - if (state != VideoPlaybackState.paused) { + if (state == VideoPlaybackState.paused) { + return; + } + + final asset = ref.read(currentAssetProvider); + if (asset != null && asset.isVideo) { ref.read(showControlsProvider.notifier).show = false; } }, diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index ba74b0496dca2..266c7636aa56c 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -71,7 +71,6 @@ class MemoryCard extends StatelessWidget { child: NativeVideoViewerPage( key: ValueKey(asset.id), asset: asset, - hideControlsTimer: const Duration(seconds: 2), showControls: false, placeholder: SizedBox.expand( child: ImmichImage( From da4bf272d7809cd2ad98dbe58334f69d63c6def6 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 18:05:22 -0500 Subject: [PATCH 24/54] fix stack handling --- .../common/gallery_stacked_children.dart | 82 +++++++++++++++++ .../lib/pages/common/gallery_viewer.page.dart | 90 ++++--------------- .../asset_viewer/asset_stack.provider.dart | 40 ++++----- .../asset_viewer/bottom_gallery_bar.dart | 11 +-- 4 files changed, 123 insertions(+), 100 deletions(-) create mode 100644 mobile/lib/pages/common/gallery_stacked_children.dart diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart new file mode 100644 index 0000000000000..6a0314087cdbc --- /dev/null +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; + +class GalleryStackedChildren extends HookConsumerWidget { + final ValueNotifier stackIndex; + + const GalleryStackedChildren(this.stackIndex, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } + + final stackId = asset.stackId; + if (stackId == null) { + return const SizedBox(); + } + + final stackElements = ref.watch(assetStackStateProvider(stackId)); + + return SizedBox( + height: 80, + child: ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemBuilder: (context, index) { + final currentAsset = stackElements.elementAt(index); + final assetId = currentAsset.remoteId; + if (assetId == null) { + return const SizedBox(); + } + + return Padding( + key: ValueKey(assetId), + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + stackIndex.value = index; + ref.read(currentAssetProvider.notifier).set(currentAsset); + }, + child: Container( + width: 60, + height: 60, + decoration: index == stackIndex.value + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Image( + fit: BoxFit.cover, + image: ImmichRemoteImageProvider(assetId: assetId), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 7355ea97e1e0d..85f412edc733a 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,19 +8,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; +import 'package:immich_mobile/pages/common/gallery_stacked_children.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart'; @@ -57,16 +56,10 @@ class GalleryViewerPage extends HookConsumerWidget { final totalAssets = useState(renderList.totalAssets); final isZoomed = useState(false); final isPlayingMotionVideo = useState(false); - final stackIndex = useState(-1); + final stackIndex = useState(0); final localPosition = useRef(null); final currentIndex = useValueNotifier(initialIndex); final loadAsset = renderList.loadAsset; - final currentAsset = loadAsset(currentIndex.value); - - final stack = showStack && currentAsset.stackCount > 0 - ? ref.watch(assetStackStateProvider(currentAsset)) - : []; - final stackElements = showStack ? [currentAsset, ...stack] : []; // // Update is playing motion video ref.listen( @@ -197,58 +190,6 @@ class GalleryViewerPage extends HookConsumerWidget { } }); - Widget buildStackedChildren() { - if (!showStack) { - return const SizedBox(); - } - - return ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - itemCount: stackElements.length, - padding: const EdgeInsets.only( - left: 5, - right: 5, - bottom: 30, - ), - itemBuilder: (context, index) { - final assetId = stackElements.elementAt(index).remoteId; - if (assetId == null) { - return const SizedBox(); - } - return Padding( - key: ValueKey(assetId), - padding: const EdgeInsets.only(right: 5), - child: GestureDetector( - onTap: () => stackIndex.value = index, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: (stackIndex.value == -1 && index == 0) || - index == stackIndex.value - ? Border.all( - color: Colors.white, - width: 2, - ) - : null, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image( - fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId), - ), - ), - ), - ), - ); - }, - ); - } - PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) { @@ -262,9 +203,7 @@ class GalleryViewerPage extends HookConsumerWidget { }, onLongPressStart: asset.isMotionPhoto ? (_, __, ___) { - if (asset.isMotionPhoto) { - isPlayingMotionVideo.value = true; - } + isPlayingMotionVideo.value = true; } : null, imageProvider: ImmichImage.imageProvider(asset: asset), @@ -316,7 +255,16 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { - final newAsset = loadAsset(index); + isPlayingMotionVideo.value = false; + var newAsset = loadAsset(index); + final stackId = newAsset.stackId; + if (stackId != null && currentIndex.value == index) { + final stackElements = + ref.read(assetStackStateProvider(newAsset.stackId!)); + if (stackIndex.value < stackElements.length) { + newAsset = stackElements.elementAt(stackIndex.value); + } + } if (newAsset.isImage && !isPlayingMotionVideo.value) { return buildImage(context, newAsset); @@ -324,8 +272,6 @@ class GalleryViewerPage extends HookConsumerWidget { return buildVideo(context, newAsset); } - log.info('GalleryViewerPage: Building gallery viewer page'); - return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -387,7 +333,7 @@ class GalleryViewerPage extends HookConsumerWidget { final newAsset = loadAsset(value); currentIndex.value = value; - stackIndex.value = -1; + stackIndex.value = 0; isPlayingMotionVideo.value = false; ref.read(currentAssetProvider.notifier).set(newAsset); @@ -418,13 +364,7 @@ class GalleryViewerPage extends HookConsumerWidget { right: 0, child: Column( children: [ - Visibility( - visible: stack.isNotEmpty, - child: SizedBox( - height: 80, - child: buildStackedChildren(), - ), - ), + GalleryStackedChildren(stackIndex), BottomGalleryBar( key: const ValueKey('bottom-bar'), renderList: renderList, diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index c3e4414b3935a..407aef16109e1 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { - final Asset _asset; + final String _stackId; final Ref _ref; - AssetStackNotifier( - this._asset, - this._ref, - ) : super([]) { - fetchStackChildren(); + AssetStackNotifier(this._stackId, this._ref) : super([]) { + _fetchStack(_stackId); } - void fetchStackChildren() async { - if (mounted) { - state = await _ref.read(assetStackProvider(_asset).future); + void _fetchStack(String stackId) async { + if (!mounted) { + return; + } + + final stack = await _ref.read(assetStackProvider(stackId).future); + if (stack.isNotEmpty) { + state = stack; } } void removeChild(int index) { if (index < state.length) { state.removeAt(index); + state = List.from(state); } } } final assetStackStateProvider = StateNotifierProvider.autoDispose - .family, Asset>( - (ref, asset) => AssetStackNotifier(asset, ref), + .family, String>( + (ref, stackId) => AssetStackNotifier(stackId, ref), ); final assetStackProvider = - FutureProvider.autoDispose.family, Asset>((ref, asset) async { - // Guard [local asset] - if (asset.remoteId == null) { - return []; - } - - return await ref + FutureProvider.autoDispose.family, String>((ref, stackId) { + return ref .watch(dbProvider) .assets .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackPrimaryAssetIdEqualTo(asset.remoteId) - .sortByFileCreatedAtDesc() + .stackIdEqualTo(stackId) + // orders primary asset first as its ID is null + .sortByStackPrimaryAssetId() + .thenByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 912d8ac449322..0a1df329cc95e 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -52,9 +52,10 @@ class BottomGalleryBar extends ConsumerWidget { } final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; final showControls = ref.watch(showControlsProvider); + final stackId = asset.stackId; - final stackItems = showStack && asset.stackCount > 0 - ? ref.watch(assetStackStateProvider(asset)) + final stackItems = showStack && stackId != null + ? ref.watch(assetStackStateProvider(stackId)) : []; bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; @@ -66,9 +67,9 @@ class BottomGalleryBar extends ConsumerWidget { final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false; void removeAssetFromStack() { - if (stackIndex > 0 && showStack) { + if (stackIndex > 0 && showStack && stackId != null) { ref - .read(assetStackStateProvider(asset).notifier) + .read(assetStackStateProvider(stackId).notifier) .removeChild(stackIndex - 1); } } @@ -137,7 +138,7 @@ class BottomGalleryBar extends ConsumerWidget { await ref .read(stackServiceProvider) - .deleteStack(asset.stackId!, [asset, ...stackItems]); + .deleteStack(asset.stackId!, stackItems); } void showStackActionItems() { From e37378bfd7cbf608615590e64395ad6a1610c1f5 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:18:22 -0500 Subject: [PATCH 25/54] improved motion photo handling --- .../lib/pages/common/gallery_viewer.page.dart | 18 ++------ .../common/native_video_viewer.page.dart | 42 +++++++++++++------ mobile/lib/routing/router.gr.dart | 8 +++- .../custom_video_player_controls.dart | 18 ++++---- mobile/lib/widgets/memories/memory_card.dart | 2 +- 5 files changed, 49 insertions(+), 39 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 85f412edc733a..d72c04dea8faf 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -55,23 +55,12 @@ class GalleryViewerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final totalAssets = useState(renderList.totalAssets); final isZoomed = useState(false); - final isPlayingMotionVideo = useState(false); + final isPlayingMotionVideo = useValueNotifier(false); final stackIndex = useState(0); final localPosition = useRef(null); final currentIndex = useValueNotifier(initialIndex); final loadAsset = renderList.loadAsset; - // // Update is playing motion video - ref.listen( - videoPlaybackValueProvider.select( - (playback) => playback.state == VideoPlaybackState.playing, - ), (_, isPlaying) { - final asset = ref.read(currentAssetProvider); - if (asset != null && asset.isMotionPhoto) { - isPlayingMotionVideo.value = isPlaying; - } - }); - Future precacheNextImage(int index) async { if (!context.mounted) { return; @@ -237,7 +226,8 @@ class GalleryViewerPage extends HookConsumerWidget { child: NativeVideoViewerPage( key: key, asset: asset, - placeholder: Image( + isPlayingMotionVideo: isPlayingMotionVideo, + image: Image( key: ValueKey(asset), image: ImmichImage.imageProvider( asset: asset, @@ -266,7 +256,7 @@ class GalleryViewerPage extends HookConsumerWidget { } } - if (newAsset.isImage && !isPlayingMotionVideo.value) { + if (newAsset.isImage && !newAsset.isMotionPhoto) { return buildImage(context, newAsset); } return buildVideo(context, newAsset); diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 9612de8f35aaa..623f19c4f0492 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -24,12 +24,17 @@ import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; final bool showControls; - final Widget placeholder; + final Widget image; + + /// Whether to display the video part of the motion photo + /// TODO: this should probably be a provider + final ValueNotifier? isPlayingMotionVideo; const NativeVideoViewerPage({ super.key, required this.asset, - required this.placeholder, + required this.image, + this.isPlayingMotionVideo, this.showControls = true, }); @@ -44,6 +49,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); + if (isPlayingMotionVideo != null) { + useListenable(isPlayingMotionVideo); + } + final showMotionVideo = + isPlayingMotionVideo != null && isPlayingMotionVideo!.value; + // When a video is opened through the timeline, `isCurrent` will immediately be true. // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. // If the swipe is completed, `isCurrent` will be true for video B after a delay. @@ -413,19 +424,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { return Stack( children: [ - placeholder, // this is always under the video to avoid flickering + // This remains under the video to avoid flickering + // For motion videos, this is the image portion of the asset + image, if (aspectRatio.value != null) - Center( - key: ValueKey(asset), - child: AspectRatio( + Visibility.maintain( + visible: asset.isVideo || showMotionVideo, + child: Center( key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent - ? NativeVideoPlayerView( - key: ValueKey(asset), - onViewReady: initController, - ) - : null, + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ), ), ), if (showControls) const Center(child: CustomVideoPlayerControls()), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 5e88d8879abbb..de8d041ed112f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1086,6 +1086,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { Key? key, required Asset asset, required Widget placeholder, + ValueNotifier? isPlayingMotionVideo, bool showControls = true, List? children, }) : super( @@ -1094,6 +1095,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { key: key, asset: asset, placeholder: placeholder, + isPlayingMotionVideo: isPlayingMotionVideo, showControls: showControls, ), initialChildren: children, @@ -1108,7 +1110,8 @@ class NativeVideoViewerRoute extends PageRouteInfo { return NativeVideoViewerPage( key: args.key, asset: args.asset, - placeholder: args.placeholder, + image: args.placeholder, + isPlayingMotionVideo: args.isPlayingMotionVideo, showControls: args.showControls, ); }, @@ -1120,6 +1123,7 @@ class NativeVideoViewerRouteArgs { this.key, required this.asset, required this.placeholder, + this.isPlayingMotionVideo, this.showControls = true, }); @@ -1129,6 +1133,8 @@ class NativeVideoViewerRouteArgs { final Widget placeholder; + final ValueNotifier? isPlayingMotionVideo; + final bool showControls; @override diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 0df8137417426..3960ce2d67b41 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -18,7 +18,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final assetIsVideo = ref.watch( + currentAssetProvider.select((asset) => asset != null && asset.isVideo), + ); final showControls = ref.watch(showControlsProvider); + final VideoPlaybackState state = + ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + // A timer to hide the controls final hideTimer = useTimer( hideTimerDuration, @@ -27,20 +33,12 @@ class CustomVideoPlayerControls extends HookConsumerWidget { return; } - final state = ref.read(videoPlaybackValueProvider).state; // Do not hide on paused - if (state == VideoPlaybackState.paused) { - return; - } - - final asset = ref.read(currentAssetProvider); - if (asset != null && asset.isVideo) { + if (state != VideoPlaybackState.paused && assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }, ); - final VideoPlaybackState state = - ref.watch(videoPlaybackValueProvider.select((value) => value.state)); final showBuffering = state == VideoPlaybackState.buffering; /// Shows the controls and starts the timer to hide them @@ -95,7 +93,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { iconColor: Colors.white, isFinished: state == VideoPlaybackState.completed, isPlaying: state == VideoPlaybackState.playing, - show: showControls, + show: assetIsVideo && showControls, onPressed: togglePlay, ), ), diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 266c7636aa56c..477003c497aaa 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -72,7 +72,7 @@ class MemoryCard extends StatelessWidget { key: ValueKey(asset.id), asset: asset, showControls: false, - placeholder: SizedBox.expand( + image: SizedBox.expand( child: ImmichImage( asset, fit: fit, From e9b1967c9b66dfeb3a3b8becd68d6cd1feed2438 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:35:11 -0500 Subject: [PATCH 26/54] use visibility for motion videos --- .../common/native_video_viewer.page.dart | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 623f19c4f0492..c7f4810260ca8 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -422,28 +422,31 @@ class NativeVideoViewerPage extends HookConsumerWidget { [], ); + final video = aspectRatio.value != null + ? Center( + key: ValueKey(asset), + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ), + ) + : null; + return Stack( children: [ // This remains under the video to avoid flickering // For motion videos, this is the image portion of the asset image, - if (aspectRatio.value != null) - Visibility.maintain( - visible: asset.isVideo || showMotionVideo, - child: Center( - key: ValueKey(asset), - child: AspectRatio( - key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent - ? NativeVideoPlayerView( - key: ValueKey(asset), - onViewReady: initController, - ) - : null, - ), - ), - ), + if (video != null) + asset.isVideo + ? video + : Visibility.maintain(visible: showMotionVideo, child: video), if (showControls) const Center(child: CustomVideoPlayerControls()), ], ); From a76e8c55b84811606b76497b764a68320726b189 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:57:42 -0500 Subject: [PATCH 27/54] error handling for async calls --- .../pages/common/native_video_viewer.page.dart | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index c7f4810260ca8..fbb2bcd58f0d9 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -65,7 +65,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { final log = Logger('NativeVideoViewerPage'); final localEntity = useMemoized(() { - if (!asset.isLocal) { + if (!asset.isLocal || asset.isMotionPhoto) { return null; } @@ -116,7 +116,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { return null; } - if (localEntity != null && asset.livePhotoVideoId == null) { + if (localEntity != null) { final file = await (await localEntity)!.file; if (file == null) { throw Exception('No file found for the video'); @@ -151,8 +151,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { return null; } - final (videoSourceRes, aspectRatioRes) = - await (createSource(), calculateAspectRatio()).wait; + late final VideoSource? videoSourceRes; + late final double? aspectRatioRes; + try { + (videoSourceRes, aspectRatioRes) = + await (createSource(), calculateAspectRatio()).wait; + } catch (error) { + log.severe( + 'Error initializing video for asset ${asset.fileName}: $error', + ); + return; + } + if (videoSourceRes == null || aspectRatioRes == null) { return; } From 05d69f7f39b25031f126b41679f3ba9f83e44f4d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 21:31:13 -0500 Subject: [PATCH 28/54] fix duplicate key error --- mobile/lib/pages/common/gallery_stacked_children.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart index 6a0314087cdbc..21593c7965d28 100644 --- a/mobile/lib/pages/common/gallery_stacked_children.dart +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -42,7 +42,7 @@ class GalleryStackedChildren extends HookConsumerWidget { } return Padding( - key: ValueKey(assetId), + key: ValueKey(currentAsset), padding: const EdgeInsets.only(right: 5), child: GestureDetector( onTap: () { From 6b2ebf5b366c5f682fc09ab7f374a87bf937dcec Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 22:26:22 -0500 Subject: [PATCH 29/54] maybe fix duplicate key error --- mobile/lib/pages/common/native_video_viewer.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index fbb2bcd58f0d9..a50d8c399f208 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -434,7 +434,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { final video = aspectRatio.value != null ? Center( - key: ValueKey(asset), + key: ValueKey(asset.id), child: AspectRatio( key: ValueKey(asset), aspectRatio: aspectRatio.value!, From 946672bdd5be9b6a7d76314a601075aefcf48475 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 23:34:48 -0500 Subject: [PATCH 30/54] increase delay for hero animation --- mobile/lib/pages/common/gallery_viewer.page.dart | 2 +- .../pages/common/native_video_viewer.page.dart | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index d72c04dea8faf..89171c2debd6e 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -106,7 +106,7 @@ class GalleryViewerPage extends HookConsumerWidget { return null; }, - [], + const [], ); void showInfo() { diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index a50d8c399f208..887f7c807c100 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -169,7 +169,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { // if opening a remote video from a hero animation, delay initialization to avoid a stutter if (!asset.isLocal && isCurrent) { - await Future.delayed(const Duration(milliseconds: 150)); + await Future.delayed(const Duration(milliseconds: 200)); } videoSource.value = videoSourceRes; @@ -376,7 +376,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); - nc.loadVideoSource(videoSource.value!); controller.value = nc; @@ -429,12 +428,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { WakelockPlus.disable(); }; }, - [], + const [], ); final video = aspectRatio.value != null ? Center( - key: ValueKey(asset.id), + key: ValueKey(asset), child: AspectRatio( key: ValueKey(asset), aspectRatio: aspectRatio.value!, @@ -452,11 +451,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { children: [ // This remains under the video to avoid flickering // For motion videos, this is the image portion of the asset - image, + Center(key: ValueKey(asset.id), child: image), if (video != null) asset.isVideo ? video - : Visibility.maintain(visible: showMotionVideo, child: video), + : Visibility.maintain( + key: ValueKey(asset), + visible: showMotionVideo, + child: video, + ), if (showControls) const Center(child: CustomVideoPlayerControls()), ], ); From 1a7904fd393a565d90e8667fdcda1c47ba674ed1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 16 Nov 2024 23:51:48 -0500 Subject: [PATCH 31/54] faster initialization for remote videos --- .../common/native_video_viewer.page.dart | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 887f7c807c100..eccda3fcd928b 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -61,6 +61,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; + final isVisible = useState(asset.isLocal || asset.isMotionPhoto); final log = Logger('NativeVideoViewerPage'); @@ -167,9 +168,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - // if opening a remote video from a hero animation, delay initialization to avoid a stutter + // if opening a remote video from a hero animation, delay visibility to avoid a stutter if (!asset.isLocal && isCurrent) { - await Future.delayed(const Duration(milliseconds: 200)); + Timer( + const Duration(milliseconds: 200), + () => isVisible.value = true, + ); } videoSource.value = videoSourceRes; @@ -431,8 +435,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { const [], ); - final video = aspectRatio.value != null - ? Center( + return Stack( + children: [ + // This remains under the video to avoid flickering + // For motion videos, this is the image portion of the asset + Center(key: ValueKey(asset.id), child: image), + Visibility.maintain( + key: ValueKey(asset), + visible: (asset.isVideo || showMotionVideo) && isVisible.value, + child: Center( key: ValueKey(asset), child: AspectRatio( key: ValueKey(asset), @@ -444,22 +455,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { ) : null, ), - ) - : null; - - return Stack( - children: [ - // This remains under the video to avoid flickering - // For motion videos, this is the image portion of the asset - Center(key: ValueKey(asset.id), child: image), - if (video != null) - asset.isVideo - ? video - : Visibility.maintain( - key: ValueKey(asset), - visible: showMotionVideo, - child: video, - ), + ), + ), if (showControls) const Center(child: CustomVideoPlayerControls()), ], ); From 10f17bfdb9a45f7fa1609be6a2b225d66669762d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 00:02:22 -0500 Subject: [PATCH 32/54] ensure dimensions for memory cards --- .../pages/common/native_video_viewer.page.dart | 2 ++ mobile/lib/widgets/memories/memory_card.dart | 16 ++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index eccda3fcd928b..dfa2af032787c 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -61,6 +61,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; + + // used to show the placeholder during hero animations for remote videos to avoid a stutter final isVisible = useState(asset.isLocal || asset.isMotionPhoto); final log = Logger('NativeVideoViewerPage'); diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 477003c497aaa..4954d0bfccc8d 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -68,13 +68,17 @@ class MemoryCard extends StatelessWidget { } else { return Hero( tag: 'memory-${asset.id}', - child: NativeVideoViewerPage( - key: ValueKey(asset.id), - asset: asset, - showControls: false, - image: SizedBox.expand( - child: ImmichImage( + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: ValueKey(asset.id), + asset: asset, + showControls: false, + image: ImmichImage( asset, + width: context.width, + height: context.height, fit: fit, ), ), From 74ac6557f1107529b152199c12ca6d41947c7dfa Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 10:45:19 -0500 Subject: [PATCH 33/54] make aspect ratio logic reusable, optimizations --- mobile/lib/entities/asset.entity.dart | 59 ++++- .../common/gallery_stacked_children.dart | 2 +- .../common/native_video_viewer.page.dart | 210 ++++++++---------- mobile/lib/services/asset.service.dart | 10 + 4 files changed, 166 insertions(+), 115 deletions(-) diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 24b88701cca08..370dd83cdf15e 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -89,6 +89,34 @@ class Asset { set local(AssetEntity? assetEntity) => _local = assetEntity; + @ignore + bool _didUpdateLocal = false; + + @ignore + bool get didUpdateLocal => _didUpdateLocal; + + Future get localAsync async { + final currentLocal = local; + if (currentLocal == null) { + throw Exception('Asset $fileName has no local data'); + } + + if (_didUpdateLocal) { + return currentLocal; + } + + final updatedLocal = _didUpdateLocal + ? currentLocal + : await currentLocal.obtainForNewProperties(); + if (updatedLocal == null) { + throw Exception('Could not fetch local data for $fileName'); + } + + local = updatedLocal; + _didUpdateLocal = true; + return updatedLocal; + } + Id id = Isar.autoIncrement; /// stores the raw SHA1 bytes as a base64 String @@ -147,9 +175,36 @@ class Asset { int stackCount; /// Aspect ratio of the asset + /// Returns null if the asset has no sync access to the exif info @ignore - double? get aspectRatio => - width == null || height == null ? 0 : width! / height!; + double? get aspectRatio { + late final double? orientatedWidth; + late final double? orientatedHeight; + + if (exifInfo != null) { + orientatedWidth = this.orientatedWidth?.toDouble(); + orientatedHeight = this.orientatedHeight?.toDouble(); + } else if (didUpdateLocal) { + final currentLocal = local; + if (currentLocal == null) { + throw Exception('Asset $fileName has no local data'); + } + orientatedWidth = currentLocal.orientatedWidth.toDouble(); + orientatedHeight = currentLocal.orientatedHeight.toDouble(); + } else { + orientatedWidth = null; + orientatedHeight = null; + } + + if (orientatedWidth != null && + orientatedHeight != null && + orientatedWidth > 0 && + orientatedHeight > 0) { + return orientatedWidth / orientatedHeight; + } + + return null; + } /// `true` if this [Asset] is present on the device @ignore diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart index 21593c7965d28..65173bb2ed06c 100644 --- a/mobile/lib/pages/common/gallery_stacked_children.dart +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -42,7 +42,7 @@ class GalleryStackedChildren extends HookConsumerWidget { } return Padding( - key: ValueKey(currentAsset), + key: ValueKey(currentAsset.id), padding: const EdgeInsets.only(right: 5), child: GestureDetector( onTap: () { diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index dfa2af032787c..c07fac0bf040f 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -49,9 +49,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); - if (isPlayingMotionVideo != null) { - useListenable(isPlayingMotionVideo); - } + useListenable(isPlayingMotionVideo); final showMotionVideo = isPlayingMotionVideo != null && isPlayingMotionVideo!.value; @@ -62,124 +60,67 @@ class NativeVideoViewerPage extends HookConsumerWidget { final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; - // used to show the placeholder during hero animations for remote videos to avoid a stutter + // Used to show the placeholder during hero animations for remote videos to avoid a stutter final isVisible = useState(asset.isLocal || asset.isMotionPhoto); final log = Logger('NativeVideoViewerPage'); - final localEntity = useMemoized(() { - if (!asset.isLocal || asset.isMotionPhoto) { - return null; - } - - final local = asset.local; - if (local == null || local.orientation > 0) { - return Future.value(local); - } - - return local.obtainForNewProperties(); - }); - - Future calculateAspectRatio() async { - if (!context.mounted) { - return null; - } - - late final double? orientatedWidth; - late final double? orientatedHeight; - - if (asset.exifInfo != null) { - orientatedWidth = asset.orientatedWidth?.toDouble(); - orientatedHeight = asset.orientatedHeight?.toDouble(); - } else if (localEntity != null) { - final entity = await localEntity; - if (entity != null) { - asset.local = entity; - orientatedWidth = entity.orientatedWidth.toDouble(); - orientatedHeight = entity.orientatedHeight.toDouble(); - } - } else { - final entity = await ref.read(assetServiceProvider).loadExif(asset); - orientatedWidth = entity.orientatedWidth?.toDouble(); - orientatedHeight = entity.orientatedHeight?.toDouble(); - } - - if (orientatedWidth != null && - orientatedHeight != null && - orientatedWidth > 0 && - orientatedHeight > 0) { - return orientatedWidth / orientatedHeight; - } - - return 1.0; - } - Future createSource() async { if (!context.mounted) { return null; } - if (localEntity != null) { - final file = await (await localEntity)!.file; - if (file == null) { - throw Exception('No file found for the video'); + try { + final local = asset.local; + if (local != null && !asset.isMotionPhoto) { + final file = await local.file; + if (file == null) { + throw Exception('No file found for the video'); + } + + final source = await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + return source; } + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; + final source = await VideoSource.init( - path: file.path, - type: VideoSourceType.file, + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), ); return source; + } catch (error) { + log.severe( + 'Error creating video source for asset ${asset.fileName}: $error', + ); + return null; } - - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; - - final source = await VideoSource.init( - path: videoUrl, - type: VideoSourceType.network, - headers: ApiService.getRequestHeaders(), - ); - return source; } - final videoSource = useState(null); - final aspectRatio = useState(null); + final videoSource = useMemoized>(() => createSource()); + final aspectRatio = useState(asset.aspectRatio); useMemoized( () async { - if (!context.mounted) { + if (!context.mounted || aspectRatio.value != null) { return null; } - late final VideoSource? videoSourceRes; - late final double? aspectRatioRes; try { - (videoSourceRes, aspectRatioRes) = - await (createSource(), calculateAspectRatio()).wait; + aspectRatio.value = + await ref.read(assetServiceProvider).getAspectRatio(asset); } catch (error) { log.severe( - 'Error initializing video for asset ${asset.fileName}: $error', + 'Error getting aspect ratio for asset ${asset.fileName}: $error', ); - return; } - - if (videoSourceRes == null || aspectRatioRes == null) { - return; - } - - // if opening a remote video from a hero animation, delay visibility to avoid a stutter - if (!asset.isLocal && isCurrent) { - Timer( - const Duration(milliseconds: 200), - () => isVisible.value = true, - ); - } - - videoSource.value = videoSourceRes; - aspectRatio.value = aspectRatioRes; }, ); @@ -197,7 +138,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { } } - // timer to mark videos as buffering if the position does not change + // Timer to mark videos as buffering if the position does not change useInterval(const Duration(seconds: 5), checkIfBuffering); // When the volume changes, set the volume @@ -286,7 +227,11 @@ class NativeVideoViewerPage extends HookConsumerWidget { ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; try { - await videoController.play(); + if (asset.isVideo || + isPlayingMotionVideo == null || + isPlayingMotionVideo!.value) { + await videoController.play(); + } await videoController.setVolume(0.9); } catch (error) { log.severe('Error playing video: $error'); @@ -362,6 +307,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { } } + void onToggleMotionVideo() async { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + try { + if (isPlayingMotionVideo!.value) { + await videoController.seekTo(0); + await videoController.play(); + } else { + await videoController.pause(); + } + } catch (error) { + log.severe('Error toggling motion video: $error'); + } + } + void removeListeners(NativeVideoPlayerController controller) { controller.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); @@ -371,19 +334,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { controller.onPlaybackEnded.removeListener(onPlaybackEnded); } - void initController(NativeVideoPlayerController nc) { + void initController(NativeVideoPlayerController nc) async { if (controller.value != null) { return; } ref.read(videoPlayerControlsProvider.notifier).reset(); ref.read(videoPlaybackValueProvider.notifier).reset(); + final source = await videoSource; + if (source == null) { + return; + } + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackEnded.addListener(onPlaybackEnded); - nc.loadVideoSource(videoSource.value!); + nc.loadVideoSource(source); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); } @@ -399,7 +367,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - // no need to delay video playback when swiping from an image to a video + // No need to delay video playback when swiping from an image to a video if (curAsset != null && !curAsset.isVideo) { currentAsset.value = value; onPlaybackReady(); @@ -421,7 +389,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { useEffect( () { + // If opening a remote video from a hero animation, delay visibility to avoid a stutter + final timer = isVisible.value + ? null + : Timer( + const Duration(milliseconds: 300), + () => isVisible.value = true, + ); + + if (isPlayingMotionVideo != null) { + isPlayingMotionVideo!.addListener(onToggleMotionVideo); + } + return () { + timer?.cancel(); + if (isPlayingMotionVideo != null) { + isPlayingMotionVideo!.removeListener(onToggleMotionVideo); + } + final playerController = controller.value; if (playerController == null) { return; @@ -442,23 +427,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { // This remains under the video to avoid flickering // For motion videos, this is the image portion of the asset Center(key: ValueKey(asset.id), child: image), - Visibility.maintain( - key: ValueKey(asset), - visible: (asset.isVideo || showMotionVideo) && isVisible.value, - child: Center( + if (aspectRatio.value != null) + Visibility.maintain( key: ValueKey(asset), - child: AspectRatio( + visible: (asset.isVideo || showMotionVideo) && isVisible.value, + child: Center( key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent - ? NativeVideoPlayerView( - key: ValueKey(asset), - onViewReady: initController, - ) - : null, + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ), ), ), - ), if (showControls) const Center(child: CustomVideoPlayerControls()), ], ); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index b2cad4dc828eb..3d2dac892b561 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -402,4 +402,14 @@ class AssetService { return exifInfo?.description ?? ""; } + + Future getAspectRatio(Asset asset) async { + if (asset.isLocal) { + await asset.localAsync; + } else if (asset.isRemote) { + asset = await loadExif(asset); + } + + return asset.aspectRatio ?? 1.0; + } } From beffb92bff5cbf8008e9422ce9aa903af3dccb2d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 13:53:06 -0500 Subject: [PATCH 34/54] refactor: move exif search from aspect ratio to orientation --- mobile/lib/entities/asset.entity.dart | 66 ++++++++++++------- mobile/lib/entities/exif_info.entity.dart | 1 + .../asset_viewer/detail_panel/file_info.dart | 6 +- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 370dd83cdf15e..17f107f3cfc8a 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -174,33 +174,17 @@ class Asset { int stackCount; - /// Aspect ratio of the asset /// Returns null if the asset has no sync access to the exif info @ignore double? get aspectRatio { - late final double? orientatedWidth; - late final double? orientatedHeight; - - if (exifInfo != null) { - orientatedWidth = this.orientatedWidth?.toDouble(); - orientatedHeight = this.orientatedHeight?.toDouble(); - } else if (didUpdateLocal) { - final currentLocal = local; - if (currentLocal == null) { - throw Exception('Asset $fileName has no local data'); - } - orientatedWidth = currentLocal.orientatedWidth.toDouble(); - orientatedHeight = currentLocal.orientatedHeight.toDouble(); - } else { - orientatedWidth = null; - orientatedHeight = null; - } + final orientatedWidth = this.orientatedWidth; + final orientatedHeight = this.orientatedHeight; if (orientatedWidth != null && orientatedHeight != null && orientatedWidth > 0 && orientatedHeight > 0) { - return orientatedWidth / orientatedHeight; + return orientatedWidth.toDouble() / orientatedHeight.toDouble(); } return null; @@ -249,13 +233,49 @@ class Asset { @ignore set byteHash(List hash) => checksum = base64.encode(hash); + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + bool? get isFlipped { + final exifInfo = this.exifInfo; + if (exifInfo != null) { + return exifInfo.isFlipped; + } + + if (didUpdateLocal) { + final local = this.local; + if (local == null) { + throw Exception('Asset $fileName has no local data'); + } + return local.orientation == 90 || local.orientation == 270; + } + + return null; + } + + /// Returns null if the asset has no sync access to the exif info @ignore - int? get orientatedWidth => - exifInfo != null && exifInfo!.isFlipped ? height : width; + @pragma('vm:prefer-inline') + int? get orientatedHeight { + final isFlipped = this.isFlipped; + if (isFlipped == null) { + return null; + } + return isFlipped ? width : height; + } + + /// Returns null if the asset has no sync access to the exif info @ignore - int? get orientatedHeight => - exifInfo != null && exifInfo!.isFlipped ? width : height; + @pragma('vm:prefer-inline') + int? get orientatedWidth { + final isFlipped = this.isFlipped; + if (isFlipped == null) { + return null; + } + + return isFlipped ? height : width; + } @override bool operator ==(other) { diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 7a0db3fdeb197..c46f3dddc15e0 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -50,6 +50,7 @@ class ExifInfo { bool? _isFlipped; @ignore + @pragma('vm:prefer-inline') bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation); @ignore diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index b2a010754675a..0dd3305302c41 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -15,10 +15,10 @@ class FileInfo extends StatelessWidget { Widget build(BuildContext context) { final textColor = context.isDarkTheme ? Colors.white : Colors.black; + final height = asset.orientatedHeight ?? asset.height; + final width = asset.orientatedWidth ?? asset.width; String resolution = - asset.orientatedHeight != null && asset.orientatedWidth != null - ? "${asset.orientatedHeight} x ${asset.orientatedWidth} " - : ""; + height != null && width != null ? "$height x $width " : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; From 7ff0066aa142a125964e9c0bf66993fece030720 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 14:59:39 -0500 Subject: [PATCH 35/54] local orientation on ios is unreliable; prefer remote --- mobile/lib/entities/asset.entity.dart | 21 +++++++-------------- mobile/lib/services/asset.service.dart | 20 ++++++++++++++++++-- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 17f107f3cfc8a..e47a4a24dbb33 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/utils/hash.dart'; @@ -92,27 +93,19 @@ class Asset { @ignore bool _didUpdateLocal = false; - @ignore - bool get didUpdateLocal => _didUpdateLocal; - Future get localAsync async { - final currentLocal = local; - if (currentLocal == null) { + final local = this.local; + if (local == null) { throw Exception('Asset $fileName has no local data'); } - if (_didUpdateLocal) { - return currentLocal; - } - - final updatedLocal = _didUpdateLocal - ? currentLocal - : await currentLocal.obtainForNewProperties(); + final updatedLocal = + _didUpdateLocal ? local : await local.obtainForNewProperties(); if (updatedLocal == null) { throw Exception('Could not fetch local data for $fileName'); } - local = updatedLocal; + this.local = updatedLocal; _didUpdateLocal = true; return updatedLocal; } @@ -242,7 +235,7 @@ class Asset { return exifInfo.isFlipped; } - if (didUpdateLocal) { + if (_didUpdateLocal && Platform.isAndroid) { final local = this.local; if (local == null) { throw Exception('Asset $fileName has no local data'); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 3d2dac892b561..7d27d1b27b0ea 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -404,12 +405,27 @@ class AssetService { } Future getAspectRatio(Asset asset) async { - if (asset.isLocal) { + // platform_manager always returns 0 for orientation on iOS, so only prefer it on Android + if (asset.isLocal && Platform.isAndroid) { await asset.localAsync; } else if (asset.isRemote) { asset = await loadExif(asset); + } else if (asset.isLocal) { + await asset.localAsync; + } + + final aspectRatio = asset.aspectRatio; + if (aspectRatio != null) { + return aspectRatio; + } + + final width = asset.width; + final height = asset.height; + if (width != null && height != null) { + // we don't know the orientation, so assume it's normal + return width / height; } - return asset.aspectRatio ?? 1.0; + return 1.0; } } From ce7e21564e332d1c5002fa4079aba94fd9e657ef Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 15:51:37 -0500 Subject: [PATCH 36/54] fix no audio in silent mode on ios --- mobile/ios/Runner/AppDelegate.swift | 75 +++++++++++++++++------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 05cb061ca58b2..446c82e78f539 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,48 +1,59 @@ -import UIKit -import shared_preferences_foundation -import Flutter import BackgroundTasks +import Flutter +import UIKit import path_provider_ios -import photo_manager import permission_handler_apple +import photo_manager +import shared_preferences_foundation @main @objc class AppDelegate: FlutterAppDelegate { - + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Required for flutter_local_notification - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + // Required for flutter_local_notification + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to set audio session category. Error: \(error)") + } + + GeneratedPluginRegistrant.register(with: self) + BackgroundServicePlugin.registerBackgroundProcessing() + + BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + BackgroundServicePlugin.setPluginRegistrantCallback { registry in + if !registry.hasPlugin("org.cocoapods.path-provider-ios") { + FLTPathProviderPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) + } + + if !registry.hasPlugin("org.cocoapods.photo-manager") { + PhotoManagerPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) } - GeneratedPluginRegistrant.register(with: self) - BackgroundServicePlugin.registerBackgroundProcessing() - - BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) - - BackgroundServicePlugin.setPluginRegistrantCallback { registry in - if !registry.hasPlugin("org.cocoapods.path-provider-ios") { - FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) - } - - if !registry.hasPlugin("org.cocoapods.photo-manager") { - PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) - } - - if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) - } - - if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { - PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) - } + if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { + SharedPreferencesPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) } - - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + + if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { + PermissionHandlerPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - + } From d82993bf576ea78880064b3ddf848785140d3fea Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 16:17:34 -0500 Subject: [PATCH 37/54] increase bottom bar opacity to account for hdr --- mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 0a1df329cc95e..5618888d566ac 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -336,12 +336,12 @@ class BottomGalleryBar extends ConsumerWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [blackOpacity90, Colors.transparent], + colors: [Colors.black, Colors.transparent], ), ), position: DecorationPosition.background, child: Padding( - padding: EdgeInsets.only(top: 40.0), + padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ if (asset.isVideo) const VideoControls(), From 6c45d04d2e91e49e343ac2e4a25cb08b5d9735b1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Nov 2024 16:18:39 -0500 Subject: [PATCH 38/54] remove unused import --- mobile/lib/constants/immich_colors.dart | 1 - mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart | 1 - 2 files changed, 2 deletions(-) diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index a49e783602b4d..8e8b8f46a325e 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -20,7 +20,6 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); -const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0); const Color red400 = Color(0xFFEF5350); final Map _themePresetsMap = { diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 5618888d566ac..157232fddc1d6 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -5,7 +5,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; From 238ca98c0f733d5a452efcc93a22bd6b79121a64 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:21:38 -0500 Subject: [PATCH 39/54] fix live photo play button not updating --- mobile/lib/constants/immich_colors.dart | 1 + .../lib/pages/common/gallery_viewer.page.dart | 11 ++-- .../common/native_video_viewer.page.dart | 63 +++++++------------ .../is_motion_video_playing.provider.dart | 23 +++++++ mobile/lib/routing/router.gr.dart | 6 -- .../widgets/asset_viewer/gallery_app_bar.dart | 10 +-- .../asset_viewer/motion_photo_button.dart | 22 +++++++ .../asset_viewer/top_control_app_bar.dart | 28 +-------- 8 files changed, 78 insertions(+), 86 deletions(-) create mode 100644 mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart create mode 100644 mobile/lib/widgets/asset_viewer/motion_photo_button.dart diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 8e8b8f46a325e..847887de8c6b6 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -21,6 +21,7 @@ const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); const Color red400 = Color(0xFFEF5350); +const Color grey200 = Color(0xFFEEEEEE); final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 89171c2debd6e..26579f030877e 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/pages/common/gallery_stacked_children.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -55,7 +56,6 @@ class GalleryViewerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final totalAssets = useState(renderList.totalAssets); final isZoomed = useState(false); - final isPlayingMotionVideo = useValueNotifier(false); final stackIndex = useState(0); final localPosition = useRef(null); final currentIndex = useValueNotifier(initialIndex); @@ -192,7 +192,7 @@ class GalleryViewerPage extends HookConsumerWidget { }, onLongPressStart: asset.isMotionPhoto ? (_, __, ___) { - isPlayingMotionVideo.value = true; + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; } : null, imageProvider: ImmichImage.imageProvider(asset: asset), @@ -226,7 +226,6 @@ class GalleryViewerPage extends HookConsumerWidget { child: NativeVideoViewerPage( key: key, asset: asset, - isPlayingMotionVideo: isPlayingMotionVideo, image: Image( key: ValueKey(asset), image: ImmichImage.imageProvider( @@ -245,7 +244,7 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { - isPlayingMotionVideo.value = false; + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; var newAsset = loadAsset(index); final stackId = newAsset.stackId; if (stackId != null && currentIndex.value == index) { @@ -278,7 +277,7 @@ class GalleryViewerPage extends HookConsumerWidget { return; } - if (asset.isImage && !isPlayingMotionVideo.value) { + if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; @@ -324,7 +323,6 @@ class GalleryViewerPage extends HookConsumerWidget { currentIndex.value = value; stackIndex.value = 0; - isPlayingMotionVideo.value = false; ref.read(currentAssetProvider.notifier).set(newAsset); if (newAsset.isVideo || newAsset.isMotionPhoto) { @@ -345,7 +343,6 @@ class GalleryViewerPage extends HookConsumerWidget { child: GalleryAppBar( key: const ValueKey('app-bar'), showInfo: showInfo, - isPlayingMotionVideo: isPlayingMotionVideo, ), ), Positioned( diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index c07fac0bf040f..6487ba57941f6 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -26,15 +27,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { final bool showControls; final Widget image; - /// Whether to display the video part of the motion photo - /// TODO: this should probably be a provider - final ValueNotifier? isPlayingMotionVideo; - const NativeVideoViewerPage({ super.key, required this.asset, required this.image, - this.isPlayingMotionVideo, this.showControls = true, }); @@ -48,10 +44,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); - - useListenable(isPlayingMotionVideo); - final showMotionVideo = - isPlayingMotionVideo != null && isPlayingMotionVideo!.value; + final showMotionVideo = useState(false); // When a video is opened through the timeline, `isCurrent` will immediately be true. // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. @@ -65,6 +58,25 @@ class NativeVideoViewerPage extends HookConsumerWidget { final log = Logger('NativeVideoViewerPage'); + ref.listen(isPlayingMotionVideoProvider, (_, value) async { + final videoController = controller.value; + if (!asset.isMotionPhoto || videoController == null || !context.mounted) { + return; + } + + showMotionVideo.value = value; + try { + if (value) { + await videoController.seekTo(0); + await videoController.play(); + } else { + await videoController.pause(); + } + } catch (error) { + log.severe('Error toggling motion video: $error'); + } + }); + Future createSource() async { if (!context.mounted) { return null; @@ -227,9 +239,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; try { - if (asset.isVideo || - isPlayingMotionVideo == null || - isPlayingMotionVideo!.value) { + if (asset.isVideo || showMotionVideo.value) { await videoController.play(); } await videoController.setVolume(0.9); @@ -307,24 +317,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { } } - void onToggleMotionVideo() async { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - try { - if (isPlayingMotionVideo!.value) { - await videoController.seekTo(0); - await videoController.play(); - } else { - await videoController.pause(); - } - } catch (error) { - log.severe('Error toggling motion video: $error'); - } - } - void removeListeners(NativeVideoPlayerController controller) { controller.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); @@ -397,16 +389,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { () => isVisible.value = true, ); - if (isPlayingMotionVideo != null) { - isPlayingMotionVideo!.addListener(onToggleMotionVideo); - } - return () { timer?.cancel(); - if (isPlayingMotionVideo != null) { - isPlayingMotionVideo!.removeListener(onToggleMotionVideo); - } - final playerController = controller.value; if (playerController == null) { return; @@ -430,7 +414,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (aspectRatio.value != null) Visibility.maintain( key: ValueKey(asset), - visible: (asset.isVideo || showMotionVideo) && isVisible.value, + visible: + (asset.isVideo || showMotionVideo.value) && isVisible.value, child: Center( key: ValueKey(asset), child: AspectRatio( diff --git a/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart new file mode 100644 index 0000000000000..4af061f9548c1 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart @@ -0,0 +1,23 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Whether to display the video part of a motion photo +final isPlayingMotionVideoProvider = + StateNotifierProvider((ref) { + return IsPlayingMotionVideo(ref); +}); + +class IsPlayingMotionVideo extends StateNotifier { + IsPlayingMotionVideo(this.ref) : super(false); + + final Ref ref; + + bool get playing => state; + + set playing(bool value) { + state = value; + } + + void toggle() { + state = !state; + } +} diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index de8d041ed112f..6f9e8cb3968d1 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1086,7 +1086,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { Key? key, required Asset asset, required Widget placeholder, - ValueNotifier? isPlayingMotionVideo, bool showControls = true, List? children, }) : super( @@ -1095,7 +1094,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { key: key, asset: asset, placeholder: placeholder, - isPlayingMotionVideo: isPlayingMotionVideo, showControls: showControls, ), initialChildren: children, @@ -1111,7 +1109,6 @@ class NativeVideoViewerRoute extends PageRouteInfo { key: args.key, asset: args.asset, image: args.placeholder, - isPlayingMotionVideo: args.isPlayingMotionVideo, showControls: args.showControls, ); }, @@ -1123,7 +1120,6 @@ class NativeVideoViewerRouteArgs { this.key, required this.asset, required this.placeholder, - this.isPlayingMotionVideo, this.showControls = true, }); @@ -1133,8 +1129,6 @@ class NativeVideoViewerRouteArgs { final Widget placeholder; - final ValueNotifier? isPlayingMotionVideo; - final bool showControls; @override diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 30cc709452033..f7e2158ea981d 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -21,13 +21,8 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; class GalleryAppBar extends ConsumerWidget { final void Function() showInfo; - final ValueNotifier isPlayingMotionVideo; - const GalleryAppBar({ - super.key, - required this.showInfo, - required this.isPlayingMotionVideo, - }); + const GalleryAppBar({super.key, required this.showInfo}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -109,15 +104,12 @@ class GalleryAppBar extends ConsumerWidget { child: TopControlAppBar( isOwner: isOwner, isPartner: isPartner, - isPlayingMotionVideo: isPlayingMotionVideo.value, asset: asset, onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, onDownloadPressed: asset.isLocal ? null : handleDownloadAsset, - onToggleMotionVideo: () => - isPlayingMotionVideo.value = !isPlayingMotionVideo.value, onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, ), diff --git a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart new file mode 100644 index 0000000000000..e4dd3555545a7 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoButton extends ConsumerWidget { + const MotionPhotoButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + + return IconButton( + onPressed: () { + ref.read(isPlayingMotionVideoProvider.notifier).toggle(); + }, + icon: isPlaying + ? const Icon(Icons.motion_photos_pause_outlined, color: grey200) + : const Icon(Icons.play_circle_outline_rounded, color: grey200), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 984b61f50cc05..2bdbb72ec03ac 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; class TopControlAppBar extends HookConsumerWidget { const TopControlAppBar({ @@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget { required this.onDownloadPressed, required this.onAddToAlbumPressed, required this.onRestorePressed, - required this.onToggleMotionVideo, - required this.isPlayingMotionVideo, required this.onFavorite, required this.onUploadPressed, required this.isOwner, @@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget { final Function onMoreInfoPressed; final VoidCallback? onUploadPressed; final VoidCallback? onDownloadPressed; - final VoidCallback onToggleMotionVideo; final VoidCallback onAddToAlbumPressed; final VoidCallback onRestorePressed; final VoidCallback onActivitiesPressed; final Function(Asset) onFavorite; - final bool isPlayingMotionVideo; final bool isOwner; final bool isPartner; @@ -57,23 +54,6 @@ class TopControlAppBar extends HookConsumerWidget { ); } - Widget buildLivePhotoButton() { - return IconButton( - onPressed: () { - onToggleMotionVideo(); - }, - icon: isPlayingMotionVideo - ? Icon( - Icons.motion_photos_pause_outlined, - color: Colors.grey[200], - ) - : Icon( - Icons.play_circle_outline_rounded, - color: Colors.grey[200], - ), - ); - } - Widget buildMoreInfoButton() { return IconButton( onPressed: () { @@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget { foregroundColor: Colors.grey[100], backgroundColor: Colors.transparent, leading: buildBackButton(), - actionsIconTheme: const IconThemeData( - size: iconSize, - ), + actionsIconTheme: const IconThemeData(size: iconSize), shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), - if (asset.livePhotoVideoId != null) buildLivePhotoButton(), + if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) From 34bdf5638e3c24fd175b66bf3b319a9c0b78e6f3 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:34:16 -0500 Subject: [PATCH 40/54] fix map marker -> galleryviewer --- mobile/lib/pages/common/gallery_viewer.page.dart | 1 + mobile/lib/pages/search/map/map.page.dart | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 26579f030877e..b21d2359ad437 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -36,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri @RoutePage() // ignore: must_be_immutable +/// Expects [currentAssetProvider] to be set before navigating to this page class GalleryViewerPage extends HookConsumerWidget { final int initialIndex; final int heroOffset; diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 8000c7e339282..10fe8de541506 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; @@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget { useEffect( () { + final currentAssetLink = + ref.read(currentAssetProvider.notifier).ref.keepAlive(); + loadMarkers(); - return null; + return currentAssetLink.close; }, [], ); @@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget { GroupAssetsBy.none, ); + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } context.pushRoute( GalleryViewerRoute( initialIndex: 0, From 7fd2c772db026606cb6a8368a3ecd5d46cb45d14 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 21 Nov 2024 20:24:34 -0500 Subject: [PATCH 41/54] remove video_player --- mobile/lib/entities/asset.entity.dart | 1 + .../lib/pages/common/video_viewer.page.dart | 166 ------------------ .../video_player_controller_provider.dart | 46 ----- .../video_player_controller_provider.g.dart | 164 ----------------- .../video_player_value_provider.dart | 24 --- mobile/lib/routing/router.gr.dart | 12 +- .../utils/hooks/chewiew_controller_hook.dart | 161 ----------------- .../widgets/asset_viewer/video_player.dart | 48 ----- 8 files changed, 7 insertions(+), 615 deletions(-) delete mode 100644 mobile/lib/pages/common/video_viewer.page.dart delete mode 100644 mobile/lib/providers/asset_viewer/video_player_controller_provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart delete mode 100644 mobile/lib/utils/hooks/chewiew_controller_hook.dart delete mode 100644 mobile/lib/widgets/asset_viewer/video_player.dart diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index e47a4a24dbb33..4bec35970a44f 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -93,6 +93,7 @@ class Asset { @ignore bool _didUpdateLocal = false; + @ignore Future get localAsync async { final local = this.local; if (local == null) { diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart deleted file mode 100644 index 1929043362613..0000000000000 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_player.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - -class VideoViewerPage extends HookConsumerWidget { - final Asset asset; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - - const VideoViewerPage({ - super.key, - required this.asset, - this.isMotionVideo = false, - this.placeholder, - this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), - this.showDownloadingIndicator = true, - this.loopVideo = false, - }); - - @override - build(BuildContext context, WidgetRef ref) { - final controller = - ref.watch(videoPlayerControllerProvider(asset: asset)).value; - // The last volume of the video used when mute is toggled - final lastVolume = useState(0.5); - - // When the volume changes, set the volume - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, mute) { - if (mute) { - controller?.setVolume(0.0); - } else { - controller?.setVolume(lastVolume.value); - } - }); - - // When the position changes, seek to the position - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { - if (controller == null) { - // No seeeking if there is no video - return; - } - - // Find the position to seek to - final Duration seek = controller.value.duration * (position / 100.0); - controller.seekTo(seek); - }); - - // When the custom video controls paus or plays - ref.listen(videoPlayerControlsProvider.select((value) => value.pause), - (lastPause, pause) { - if (pause) { - controller?.pause(); - } else { - controller?.play(); - } - }); - - // Updates the [videoPlaybackValueProvider] with the current - // position and duration of the video from the Chewie [controller] - // Also sets the error if there is an error in the playback - void updateVideoPlayback() { - final videoPlayback = VideoPlaybackValue.fromController(controller); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - final state = videoPlayback.state; - - // Enable the WakeLock while the video is playing - if (state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - } - - // Adds and removes the listener to the video player - useEffect( - () { - Future.microtask( - () => ref.read(videoPlayerControlsProvider.notifier).reset(), - ); - // Guard no controller - if (controller == null) { - return null; - } - - // Hide the controls - // Done in a microtask to avoid setting the state while the is building - if (!isMotionVideo) { - Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; - }); - } - - // Subscribes to listener - Future.microtask(() { - controller.addListener(updateVideoPlayback); - }); - return () { - // Removes listener when we dispose - controller.removeListener(updateVideoPlayback); - controller.pause(); - }; - }, - [controller], - ); - - return PopScope( - onPopInvokedWithResult: (didPop, _) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: Stack( - children: [ - Visibility( - visible: controller == null, - child: Stack( - children: [ - if (placeholder != null) placeholder!, - const Positioned.fill( - child: Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 500), - ), - ), - ), - ], - ), - ), - if (controller != null) - SizedBox( - height: size.height, - width: size.width, - child: VideoPlayerViewer( - controller: controller, - isMotionVideo: isMotionVideo, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - loopVideo: loopVideo, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart deleted file mode 100644 index 969e181cbb0ad..0000000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:video_player/video_player.dart'; - -part 'video_player_controller_provider.g.dart'; - -@riverpod -Future videoPlayerController( - VideoPlayerControllerRef ref, { - required Asset asset, -}) async { - late VideoPlayerController controller; - if (asset.isLocal && asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - controller = VideoPlayerController.file(file); - } else { - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; - - final url = Uri.parse(videoUrl); - controller = VideoPlayerController.networkUrl( - url, - httpHeaders: ApiService.getRequestHeaders(), - videoPlayerOptions: asset.livePhotoVideoId != null - ? VideoPlayerOptions(mixWithOthers: true) - : VideoPlayerOptions(mixWithOthers: false), - ); - } - - await controller.initialize(); - - ref.onDispose(() { - controller.dispose(); - }); - - return controller; -} diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart deleted file mode 100644 index 00ad37648a85e..0000000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart +++ /dev/null @@ -1,164 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'video_player_controller_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$videoPlayerControllerHash() => - r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [videoPlayerController]. -@ProviderFor(videoPlayerController) -const videoPlayerControllerProvider = VideoPlayerControllerFamily(); - -/// See also [videoPlayerController]. -class VideoPlayerControllerFamily - extends Family> { - /// See also [videoPlayerController]. - const VideoPlayerControllerFamily(); - - /// See also [videoPlayerController]. - VideoPlayerControllerProvider call({ - required Asset asset, - }) { - return VideoPlayerControllerProvider( - asset: asset, - ); - } - - @override - VideoPlayerControllerProvider getProviderOverride( - covariant VideoPlayerControllerProvider provider, - ) { - return call( - asset: provider.asset, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'videoPlayerControllerProvider'; -} - -/// See also [videoPlayerController]. -class VideoPlayerControllerProvider - extends AutoDisposeFutureProvider { - /// See also [videoPlayerController]. - VideoPlayerControllerProvider({ - required Asset asset, - }) : this._internal( - (ref) => videoPlayerController( - ref as VideoPlayerControllerRef, - asset: asset, - ), - from: videoPlayerControllerProvider, - name: r'videoPlayerControllerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$videoPlayerControllerHash, - dependencies: VideoPlayerControllerFamily._dependencies, - allTransitiveDependencies: - VideoPlayerControllerFamily._allTransitiveDependencies, - asset: asset, - ); - - VideoPlayerControllerProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.asset, - }) : super.internal(); - - final Asset asset; - - @override - Override overrideWith( - FutureOr Function(VideoPlayerControllerRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: VideoPlayerControllerProvider._internal( - (ref) => create(ref as VideoPlayerControllerRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - asset: asset, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _VideoPlayerControllerProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is VideoPlayerControllerProvider && other.asset == asset; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, asset.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin VideoPlayerControllerRef - on AutoDisposeFutureProviderRef { - /// The parameter `asset` of this provider. - Asset get asset; -} - -class _VideoPlayerControllerProviderElement - extends AutoDisposeFutureProviderElement - with VideoPlayerControllerRef { - _VideoPlayerControllerProviderElement(super.provider); - - @override - Asset get asset => (origin as VideoPlayerControllerProvider).asset; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index 3c9ac0b99a6c5..1a3c54e9e9293 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:video_player/video_player.dart'; enum VideoPlaybackState { initializing, @@ -54,29 +53,6 @@ class VideoPlaybackValue { ); } - factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { - final video = controller?.value; - late VideoPlaybackState s; - if (video == null) { - s = VideoPlaybackState.initializing; - } else if (video.isCompleted) { - s = VideoPlaybackState.completed; - } else if (video.isPlaying) { - s = VideoPlaybackState.playing; - } else if (video.isBuffering) { - s = VideoPlaybackState.buffering; - } else { - s = VideoPlaybackState.paused; - } - - return VideoPlaybackValue( - position: video?.position ?? Duration.zero, - duration: video?.duration ?? Duration.zero, - state: s, - volume: video?.volume ?? 0.0, - ); - } - VideoPlaybackValue copyWith({ Duration? position, Duration? duration, diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 6f9e8cb3968d1..48ee4db5fd2b1 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1085,7 +1085,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { NativeVideoViewerRoute({ Key? key, required Asset asset, - required Widget placeholder, + required Widget image, bool showControls = true, List? children, }) : super( @@ -1093,7 +1093,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { args: NativeVideoViewerRouteArgs( key: key, asset: asset, - placeholder: placeholder, + image: image, showControls: showControls, ), initialChildren: children, @@ -1108,7 +1108,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { return NativeVideoViewerPage( key: args.key, asset: args.asset, - image: args.placeholder, + image: args.image, showControls: args.showControls, ); }, @@ -1119,7 +1119,7 @@ class NativeVideoViewerRouteArgs { const NativeVideoViewerRouteArgs({ this.key, required this.asset, - required this.placeholder, + required this.image, this.showControls = true, }); @@ -1127,13 +1127,13 @@ class NativeVideoViewerRouteArgs { final Asset asset; - final Widget placeholder; + final Widget image; final bool showControls; @override String toString() { - return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, placeholder: $placeholder, showControls: $showControls}'; + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}'; } } diff --git a/mobile/lib/utils/hooks/chewiew_controller_hook.dart b/mobile/lib/utils/hooks/chewiew_controller_hook.dart deleted file mode 100644 index 2868e896cf2f4..0000000000000 --- a/mobile/lib/utils/hooks/chewiew_controller_hook.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:video_player/video_player.dart'; - -/// Provides the initialized video player controller -/// If the asset is local, use the local file -/// Otherwise, use a video player with a URL -ChewieController useChewieController({ - required VideoPlayerController controller, - EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only( - bottom: 100, - ), - bool showOptions = true, - bool showControlsOnInitialize = false, - bool autoPlay = true, - bool allowFullScreen = false, - bool allowedScreenSleep = false, - bool showControls = true, - bool loopVideo = false, - Widget? customControls, - Widget? placeholder, - Duration hideControlsTimer = const Duration(seconds: 1), - VoidCallback? onPlaying, - VoidCallback? onPaused, - VoidCallback? onVideoEnded, -}) { - return use( - _ChewieControllerHook( - controller: controller, - placeholder: placeholder, - showOptions: showOptions, - controlsSafeAreaMinimum: controlsSafeAreaMinimum, - autoPlay: autoPlay, - allowFullScreen: allowFullScreen, - customControls: customControls, - hideControlsTimer: hideControlsTimer, - showControlsOnInitialize: showControlsOnInitialize, - showControls: showControls, - loopVideo: loopVideo, - allowedScreenSleep: allowedScreenSleep, - onPlaying: onPlaying, - onPaused: onPaused, - onVideoEnded: onVideoEnded, - ), - ); -} - -class _ChewieControllerHook extends Hook { - final VideoPlayerController controller; - final EdgeInsets controlsSafeAreaMinimum; - final bool showOptions; - final bool showControlsOnInitialize; - final bool autoPlay; - final bool allowFullScreen; - final bool allowedScreenSleep; - final bool showControls; - final bool loopVideo; - final Widget? customControls; - final Widget? placeholder; - final Duration hideControlsTimer; - final VoidCallback? onPlaying; - final VoidCallback? onPaused; - final VoidCallback? onVideoEnded; - - const _ChewieControllerHook({ - required this.controller, - this.controlsSafeAreaMinimum = const EdgeInsets.only( - bottom: 100, - ), - this.showOptions = true, - this.showControlsOnInitialize = false, - this.autoPlay = true, - this.allowFullScreen = false, - this.allowedScreenSleep = false, - this.showControls = true, - this.loopVideo = false, - this.customControls, - this.placeholder, - this.hideControlsTimer = const Duration(seconds: 3), - this.onPlaying, - this.onPaused, - this.onVideoEnded, - }); - - @override - createState() => _ChewieControllerHookState(); -} - -class _ChewieControllerHookState - extends HookState { - late ChewieController chewieController = ChewieController( - videoPlayerController: hook.controller, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - looping: hook.loopVideo, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - - @override - void dispose() { - chewieController.dispose(); - super.dispose(); - } - - @override - ChewieController build(BuildContext context) { - return chewieController; - } - - /* - /// Initializes the chewie controller and video player controller - Future _initialize() async { - if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await hook.asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - videoPlayerController = VideoPlayerController.file(file); - } else { - // Use a network URL for the video player controller - final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint); - final String videoUrl = hook.asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback'; - - final url = Uri.parse(videoUrl); - final accessToken = store.Store.get(StoreKey.accessToken); - - videoPlayerController = VideoPlayerController.networkUrl( - url, - httpHeaders: {"x-immich-user-token": accessToken}, - ); - } - - await videoPlayerController!.initialize(); - - chewieController = ChewieController( - videoPlayerController: videoPlayerController!, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - } - */ -} diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart deleted file mode 100644 index ebf158b59a5fb..0000000000000 --- a/mobile/lib/widgets/asset_viewer/video_player.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart'; -import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPlayerViewer extends HookConsumerWidget { - final VideoPlayerController controller; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - - const VideoPlayerViewer({ - super.key, - required this.controller, - required this.isMotionVideo, - this.placeholder, - required this.hideControlsTimer, - required this.showControls, - required this.showDownloadingIndicator, - required this.loopVideo, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final chewie = useChewieController( - controller: controller, - controlsSafeAreaMinimum: const EdgeInsets.only( - bottom: 100, - ), - placeholder: SizedBox.expand(child: placeholder), - customControls: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, - ), - showControls: showControls && !isMotionVideo, - hideControlsTimer: hideControlsTimer, - loopVideo: loopVideo, - ); - - return Chewie( - controller: chewie, - ); - } -} From 2e8dddba21c8cf4c02833fc6a10144e34a512e01 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 1 Dec 2024 00:14:55 -0500 Subject: [PATCH 42/54] fix hdr playback on android --- mobile/android/app/build.gradle | 4 ++-- mobile/android/app/src/main/AndroidManifest.xml | 2 +- mobile/android/build.gradle | 4 ++-- mobile/ios/Podfile.lock | 8 ++++---- .../lib/pages/common/native_video_viewer.page.dart | 13 +++++-------- mobile/pubspec.lock | 4 ++-- mobile/pubspec.yaml | 2 +- 7 files changed, 17 insertions(+), 20 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 506ee9d1a4b0a..0ec511d9f125e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 34 + compileSdkVersion 35 compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -47,7 +47,7 @@ android { defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 - targetSdkVersion 34 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c85ce136844bc..8f239015dd62a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ + android:value="true" /> Date: Sun, 1 Dec 2024 02:30:04 -0500 Subject: [PATCH 43/54] fix looping --- .../common/native_video_viewer.page.dart | 91 +++++++++---------- .../video_player_controls_provider.dart | 3 + .../custom_video_player_controls.dart | 4 +- mobile/pubspec.lock | 4 +- mobile/pubspec.yaml | 2 +- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 2a072dd35a312..c8696dc22c021 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -37,11 +37,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final loopVideo = ref.watch( - appSettingsServiceProvider.select( - (settings) => settings.getSetting(AppSettingsEnum.loopVideo), - ), - ); final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); @@ -186,28 +181,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { interval: const Duration(milliseconds: 100), maxWaitTime: const Duration(milliseconds: 200), ); - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) async { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - // Find the position to seek to - final seek = position ~/ 1; - if (seek != playbackInfo.position) { - seekDebouncer.run(() => playerController.seekTo(seek)); - } - }); - - // // When the custom video controls pause or play - ref.listen(videoPlayerControlsProvider.select((value) => value.pause), - (_, pause) async { + Future onPlayerControlsPlayChange(bool? _, bool pause) async { final videoController = controller.value; if (videoController == null || !context.mounted) { return; @@ -228,8 +202,39 @@ class NativeVideoViewerPage extends HookConsumerWidget { } catch (error) { log.severe('Error pausing or playing video: $error'); } + } + + ref.listen(videoPlayerControlsProvider.select((value) => value.position), + (_, position) { + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { + return; + } + + // Find the position to seek to + final seek = position ~/ 1; + if (seek != playbackInfo.position) { + seekDebouncer.run(() => playerController.seekTo(seek)); + } + + if (Platform.isIOS && + seek == 0 && + !ref.read(videoPlayerControlsProvider.notifier).paused) { + onPlayerControlsPlayChange(null, false); + } }); + // // When the custom video controls pause or play + ref.listen( + videoPlayerControlsProvider.select((value) => value.pause), + onPlayerControlsPlayChange, + ); + void onPlaybackReady() async { final videoController = controller.value; if (videoController == null || !isCurrent || !context.mounted) { @@ -258,12 +263,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - // No need to update the UI when it's about to loop - if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) { - return; - } - ref.read(videoPlaybackValueProvider.notifier).status = - videoPlayback.state; if (videoPlayback.state == VideoPlaybackState.playing) { // Sync with the controls playing WakelockPlus.enable(); @@ -271,6 +270,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { // Sync with the controls pause WakelockPlus.disable(); } + + ref.read(videoPlaybackValueProvider.notifier).status = + videoPlayback.state; } void onPlaybackPositionChanged() { @@ -302,28 +304,16 @@ class NativeVideoViewerPage extends HookConsumerWidget { } } - void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - if (!loopVideo) { - WakelockPlus.disable(); - } - } - void removeListeners(NativeVideoPlayerController controller) { controller.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); controller.onPlaybackStatusChanged .removeListener(onPlaybackStatusChanged); controller.onPlaybackReady.removeListener(onPlaybackReady); - controller.onPlaybackEnded.removeListener(onPlaybackEnded); } void initController(NativeVideoPlayerController nc) async { - if (controller.value != null) { + if (controller.value != null || !context.mounted) { return; } ref.read(videoPlayerControlsProvider.notifier).reset(); @@ -337,10 +327,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); - nc.onPlaybackEnded.addListener(onPlaybackEnded); + nc.loadVideoSource(source).catchError((error) { + log.severe('Error loading video source: $error'); + }); + final loopVideo = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.loopVideo); nc.setLoop(loopVideo); - nc.loadVideoSource(source); + controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); } diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index 20f8fd7d2e5db..24ca9cb18ec71 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; class VideoPlaybackControls { const VideoPlaybackControls({ @@ -40,6 +41,7 @@ class VideoPlayerControls extends StateNotifier { double get position => state.position; bool get mute => state.mute; + bool get paused => state.pause; set position(double value) { if (state.position == value) { @@ -111,5 +113,6 @@ class VideoPlayerControls extends StateNotifier { mute: state.mute, pause: false, ); + ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero; } } diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 3960ce2d67b41..a04936ae3d141 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -34,7 +34,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { } // Do not hide on paused - if (state != VideoPlaybackState.paused && assetIsVideo) { + if (state != VideoPlaybackState.paused && + state != VideoPlaybackState.completed && + assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f7d29604bb604..fd8544f85e995 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1028,8 +1028,8 @@ packages: dependency: "direct main" description: path: "." - ref: "feat/exoplayer" - resolved-ref: "2139230b334b22b87de1dba47cc5632e5d172840" + ref: "68ea203" + resolved-ref: "68ea2030ba7aceb1bc44b683ff0b742fd1a52d2f" url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 40b4b9ff339e1..2fd40ff7debd2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -67,7 +67,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: feat/exoplayer + ref: 68ea203 #image editing packages crop_image: ^1.0.13 From 37eb0046cee7db92e365b14bfb26955c8e6ff629 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 1 Dec 2024 16:36:12 -0500 Subject: [PATCH 44/54] remove unused dependencies --- mobile/ios/Podfile.lock | 7 ---- mobile/pubspec.lock | 72 ----------------------------------------- mobile/pubspec.yaml | 3 -- 3 files changed, 82 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index ea7fca0e24432..338ec864a9401 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -95,9 +95,6 @@ PODS: - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS - wakelock_plus (0.0.1): - Flutter @@ -127,7 +124,6 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: @@ -191,8 +187,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" wakelock_plus: :path: ".symlinks/plugins/wakelock_plus/ios" @@ -229,7 +223,6 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index fd8544f85e995..99b1f153e7d7f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -214,14 +214,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - chewie: - dependency: "direct main" - description: - name: chewie - sha256: "2243e41e79e865d426d9dd9c1a9624aa33c4ad11de2d0cd680f826e2cd30e879" - url: "https://pub.dev" - source: hosted - version: "1.8.3" ci: dependency: transitive description: @@ -318,14 +310,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" custom_lint: dependency: "direct dev" description: @@ -1033,14 +1017,6 @@ packages: url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" nm: dependency: transitive description: @@ -1264,14 +1240,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" pub_semver: dependency: transitive description: @@ -1717,46 +1685,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - video_player: - dependency: "direct main" - description: - name: video_player - sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" - url: "https://pub.dev" - source: hosted - version: "2.9.2" - video_player_android: - dependency: "direct main" - description: - name: video_player_android - sha256: "4de50df9ee786f5891d3281e1e633d7b142ef1acf47392592eb91cba5d355849" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c - url: "https://pub.dev" - source: hosted - version: "2.6.1" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" - url: "https://pub.dev" - source: hosted - version: "6.2.2" - video_player_web: - dependency: transitive - description: - name: video_player_web - sha256: "6dcdd298136523eaf7dfc31abaf0dfba9aa8a8dbc96670e87e9d42b6f2caf774" - url: "https://pub.dev" - source: hosted - version: "2.3.2" vm_service: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2fd40ff7debd2..7fd78a4d3e41c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -25,9 +25,6 @@ dependencies: intl: ^0.19.0 auto_route: ^9.2.0 fluttertoast: ^8.2.4 - video_player: ^2.9.2 - video_player_android: 2.6.0 - chewie: ^1.7.4 socket_io_client: ^2.0.3+1 maplibre_gl: 0.19.0+2 geolocator: ^11.0.0 # used to move to current location in map view From 0f7a470e41421a0532a3ab8bd9c4d94f00187acb Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 1 Dec 2024 17:45:51 -0500 Subject: [PATCH 45/54] update to latest player commit --- mobile/pubspec.lock | 4 ++-- mobile/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 99b1f153e7d7f..9203dcdf825e5 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1012,8 +1012,8 @@ packages: dependency: "direct main" description: path: "." - ref: "68ea203" - resolved-ref: "68ea2030ba7aceb1bc44b683ff0b742fd1a52d2f" + ref: ac78487 + resolved-ref: ac78487b9a87c9e72cd15b428270a905ac551f29 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7fd78a4d3e41c..a037f9b947206 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -64,7 +64,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: 68ea203 + ref: ac78487 #image editing packages crop_image: ^1.0.13 From bae52396bd00d6d4c28c6f1f6ca4f94d8e1fd07d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:19:35 -0500 Subject: [PATCH 46/54] fix player controls hiding when video is not playing --- .../lib/widgets/asset_viewer/custom_video_player_controls.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index a04936ae3d141..4d4e5bcaed557 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -32,6 +32,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { if (!context.mounted) { return; } + final state = ref.read(videoPlaybackValueProvider).state; // Do not hide on paused if (state != VideoPlaybackState.paused && From cfaa4f9f4c0377d708459c5584555fd0651b0d1f Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:40:08 -0500 Subject: [PATCH 47/54] fix restart video --- .../common/native_video_viewer.page.dart | 88 ++++++------------- .../video_player_controls_provider.dart | 69 ++++----------- .../custom_video_player_controls.dart | 6 -- 3 files changed, 40 insertions(+), 123 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index c8696dc22c021..c089e39545f69 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -150,30 +150,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { // Timer to mark videos as buffering if the position does not change useInterval(const Duration(seconds: 5), checkIfBuffering); - // When the volume changes, set the volume - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, mute) async { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - try { - if (mute && playbackInfo.volume != 0.0) { - await playerController.setVolume(0.0); - } else if (!mute && playbackInfo.volume != 0.9) { - await playerController.setVolume(0.9); - } - } catch (error) { - log.severe('Error setting volume: $error'); - } - }); - // When the position changes, seek to the position // Debounce the seek to avoid seeking too often // But also don't delay the seek too much to maintain visual feedback @@ -181,31 +157,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { interval: const Duration(milliseconds: 100), maxWaitTime: const Duration(milliseconds: 200), ); - Future onPlayerControlsPlayChange(bool? _, bool pause) async { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - try { - if (pause) { - await videoController.pause(); - } else { - await videoController.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } - } - - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { final playerController = controller.value; if (playerController == null) { return; @@ -216,24 +168,34 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - // Find the position to seek to - final seek = position ~/ 1; - if (seek != playbackInfo.position) { - seekDebouncer.run(() => playerController.seekTo(seek)); + if (newControls.restarted) { + debugPrint('!!!!!!!!!!!!newControls.restarted!!!!!!!!!!!!!!'); } - if (Platform.isIOS && - seek == 0 && - !ref.read(videoPlayerControlsProvider.notifier).paused) { - onPlayerControlsPlayChange(null, false); + final oldSeek = (oldControls?.position ?? 0) ~/ 1; + final newSeek = newControls.position ~/ 1; + if (oldSeek != newSeek || newControls.restarted) { + seekDebouncer.run(() => playerController.seekTo(newSeek)); } - }); - // // When the custom video controls pause or play - ref.listen( - videoPlayerControlsProvider.select((value) => value.pause), - onPlayerControlsPlayChange, - ); + if (oldControls?.pause != newControls.pause || newControls.restarted) { + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + try { + if (newControls.pause) { + await playerController.pause(); + } else { + await playerController.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } + }); void onPlaybackReady() async { final videoController = controller.value; diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index 24ca9cb18ec71..10efc5abd8b4f 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -4,13 +4,13 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider class VideoPlaybackControls { const VideoPlaybackControls({ required this.position, - required this.mute, required this.pause, + this.restarted = false, }); final double position; - final bool mute; final bool pause; + final bool restarted; } final videoPlayerControlsProvider = @@ -18,11 +18,8 @@ final videoPlayerControlsProvider = return VideoPlayerControls(ref); }); -const videoPlayerControlsDefault = VideoPlaybackControls( - position: 0, - pause: false, - mute: false, -); +const videoPlayerControlsDefault = + VideoPlaybackControls(position: 0, pause: false); class VideoPlayerControls extends StateNotifier { VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); @@ -40,7 +37,6 @@ class VideoPlayerControls extends StateNotifier { } double get position => state.position; - bool get mute => state.mute; bool get paused => state.pause; set position(double value) { @@ -48,31 +44,7 @@ class VideoPlayerControls extends StateNotifier { return; } - state = VideoPlaybackControls( - position: value, - mute: state.mute, - pause: state.pause, - ); - } - - set mute(bool value) { - if (state.mute == value) { - return; - } - - state = VideoPlaybackControls( - position: state.position, - mute: value, - pause: state.pause, - ); - } - - void toggleMute() { - state = VideoPlaybackControls( - position: state.position, - mute: !state.mute, - pause: state.pause, - ); + state = VideoPlaybackControls(position: value, pause: state.pause); } void pause() { @@ -80,11 +52,7 @@ class VideoPlayerControls extends StateNotifier { return; } - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: true, - ); + state = VideoPlaybackControls(position: state.position, pause: true); } void play() { @@ -92,27 +60,20 @@ class VideoPlayerControls extends StateNotifier { return; } - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: false, - ); + state = VideoPlaybackControls(position: state.position, pause: false); } void togglePlay() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: !state.pause, - ); + state = + VideoPlaybackControls(position: state.position, pause: !state.pause); } void restart() { - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: false, - ); - ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero; + state = VideoPlaybackControls(position: 0, pause: false, restarted: true); + ref.read(videoPlaybackValueProvider.notifier).value = + ref.read(videoPlaybackValueProvider.notifier).value.copyWith( + state: VideoPlaybackState.playing, + position: Duration.zero, + ); } } diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 4d4e5bcaed557..d759b0d80b3e6 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -50,12 +50,6 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ref.read(showControlsProvider.notifier).show = true; } - // When we mute, show the controls - ref.listen(videoPlayerControlsProvider.select((v) => v.mute), - (previous, next) { - showControlsAndStartHideTimer(); - }); - // When we change position, show or hide timer ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { From 71660ab1b2a8c33de49b6b296b2662b83a7a4c90 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:59:38 -0500 Subject: [PATCH 48/54] stop showing motion video after ending when looping is disabled --- .../pages/common/native_video_viewer.page.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index c089e39545f69..119dfba0d2146 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -266,12 +266,28 @@ class NativeVideoViewerPage extends HookConsumerWidget { } } + void onPlaybackEnded() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + if (showMotionVideo.value && + videoController.playbackInfo?.status == PlaybackStatus.stopped && + !ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.loopVideo)) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + } + } + void removeListeners(NativeVideoPlayerController controller) { controller.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); controller.onPlaybackStatusChanged .removeListener(onPlaybackStatusChanged); controller.onPlaybackReady.removeListener(onPlaybackReady); + controller.onPlaybackEnded.removeListener(onPlaybackEnded); } void initController(NativeVideoPlayerController nc) async { @@ -289,6 +305,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); + nc.onPlaybackEnded.addListener(onPlaybackEnded); nc.loadVideoSource(source).catchError((error) { log.severe('Error loading video source: $error'); From b76f81b7662d8406c459645dfd90992dd25c04db Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:03:20 -0500 Subject: [PATCH 49/54] delay video initialization to avoid placeholder flicker --- .../lib/pages/common/native_video_viewer.page.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 119dfba0d2146..36f64e31d8a1c 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -168,10 +168,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - if (newControls.restarted) { - debugPrint('!!!!!!!!!!!!newControls.restarted!!!!!!!!!!!!!!'); - } - final oldSeek = (oldControls?.position ?? 0) ~/ 1; final newSeek = newControls.position ~/ 1; if (oldSeek != newSeek || newControls.restarted) { @@ -338,7 +334,13 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Delay the video playback to avoid a stutter in the swipe animation - Timer(const Duration(milliseconds: 300), () { + Timer( + Platform.isIOS + ? const Duration(milliseconds: 300) + // On Android, the placeholder of the first opened video + // can briefly be seen and cause a flicker effect unless the animation is delayed longer + // - probably a bug in PhotoViewGallery's animation handling + : const Duration(milliseconds: 500), () { if (!context.mounted) { return; } From fecfce6b07161f650efb6d48354d17d0cc6d58e0 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:22:56 -0500 Subject: [PATCH 50/54] faster animation --- mobile/lib/extensions/scroll_extensions.dart | 22 +++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 838c2afd3ce8d..5bbd73163a6b8 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -1,11 +1,5 @@ import 'package:flutter/cupertino.dart'; -const _spring = SpringDescription( - mass: 40, - stiffness: 100, - damping: 1, -); - // https://stackoverflow.com/a/74453792 class FastScrollPhysics extends ScrollPhysics { const FastScrollPhysics({super.parent}); @@ -16,7 +10,11 @@ class FastScrollPhysics extends ScrollPhysics { } @override - SpringDescription get spring => _spring; + SpringDescription get spring => const SpringDescription( + mass: 40, + stiffness: 100, + damping: 1, + ); } class FastClampingScrollPhysics extends ClampingScrollPhysics { @@ -28,5 +26,13 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { } @override - SpringDescription get spring => _spring; + SpringDescription get spring => const SpringDescription( + // When swiping between videos on Android, the placeholder of the first opened video + // can briefly be seen and cause a flicker effect if the video begins to initialize + // before the animation finishes - probably a bug in PhotoViewGallery's animation handling + // Making the animation faster is not just stylistic, but also helps to avoid this flicker + mass: 80, + stiffness: 100, + damping: 1, + ); } From 1415fc9f5261047f3a002c309911549ff875569d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:23:31 -0500 Subject: [PATCH 51/54] shorter delay --- mobile/lib/pages/common/native_video_viewer.page.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 36f64e31d8a1c..91d0f22ec7d80 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -337,10 +337,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { Timer( Platform.isIOS ? const Duration(milliseconds: 300) - // On Android, the placeholder of the first opened video - // can briefly be seen and cause a flicker effect unless the animation is delayed longer - // - probably a bug in PhotoViewGallery's animation handling - : const Duration(milliseconds: 500), () { + : const Duration(milliseconds: 400), () { if (!context.mounted) { return; } From 80a89eee2b81c55c8f5322848af626905a3c4933 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:26:36 -0500 Subject: [PATCH 52/54] small delay for image -> video on android --- mobile/lib/pages/common/native_video_viewer.page.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 91d0f22ec7d80..3d0e24e1926bc 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -326,8 +326,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } + final imageToVideo = curAsset != null && !curAsset.isVideo; + // No need to delay video playback when swiping from an image to a video - if (curAsset != null && !curAsset.isVideo) { + if (imageToVideo && Platform.isIOS) { currentAsset.value = value; onPlaybackReady(); return; @@ -337,7 +339,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { Timer( Platform.isIOS ? const Duration(milliseconds: 300) - : const Duration(milliseconds: 400), () { + : imageToVideo + ? const Duration(milliseconds: 200) + : const Duration(milliseconds: 400), () { if (!context.mounted) { return; } From c562def2c6c3995650dc6cec4f623c26911aa838 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Dec 2024 13:10:49 -0600 Subject: [PATCH 53/54] fix: lint --- mobile/ios/Podfile.lock | 2 +- .../providers/asset_viewer/video_player_controls_provider.dart | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 338ec864a9401..2e71937a84776 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -227,4 +227,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.16.0 +COCOAPODS: 1.15.2 diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index 10efc5abd8b4f..69be91480ffc2 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -69,7 +69,8 @@ class VideoPlayerControls extends StateNotifier { } void restart() { - state = VideoPlaybackControls(position: 0, pause: false, restarted: true); + state = + const VideoPlaybackControls(position: 0, pause: false, restarted: true); ref.read(videoPlaybackValueProvider.notifier).value = ref.read(videoPlaybackValueProvider.notifier).value.copyWith( state: VideoPlaybackState.playing, From 0dc3ae07d4b1f332c86d58425a4f21fc47fc7ca2 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Wed, 4 Dec 2024 15:46:21 -0500 Subject: [PATCH 54/54] hide stacked children when controls are hidden, avoid bottom bar dropping --- .../common/gallery_stacked_children.dart | 107 ++++++++++-------- .../lib/pages/common/gallery_viewer.page.dart | 12 +- .../common/native_video_viewer.page.dart | 2 +- .../asset_viewer/bottom_gallery_bar.dart | 8 +- 4 files changed, 71 insertions(+), 58 deletions(-) diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart index 65173bb2ed06c..eafc3250494f9 100644 --- a/mobile/lib/pages/common/gallery_stacked_children.dart +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; class GalleryStackedChildren extends HookConsumerWidget { @@ -22,60 +23,68 @@ class GalleryStackedChildren extends HookConsumerWidget { } final stackElements = ref.watch(assetStackStateProvider(stackId)); + final showControls = ref.watch(showControlsProvider); - return SizedBox( - height: 80, - child: ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - itemCount: stackElements.length, - padding: const EdgeInsets.only( - left: 5, - right: 5, - bottom: 30, - ), - itemBuilder: (context, index) { - final currentAsset = stackElements.elementAt(index); - final assetId = currentAsset.remoteId; - if (assetId == null) { - return const SizedBox(); - } + return IgnorePointer( + ignoring: !showControls, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: showControls ? 1.0 : 0.0, + child: SizedBox( + height: 80, + child: ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemBuilder: (context, index) { + final currentAsset = stackElements.elementAt(index); + final assetId = currentAsset.remoteId; + if (assetId == null) { + return const SizedBox(); + } - return Padding( - key: ValueKey(currentAsset.id), - padding: const EdgeInsets.only(right: 5), - child: GestureDetector( - onTap: () { - stackIndex.value = index; - ref.read(currentAssetProvider.notifier).set(currentAsset); - }, - child: Container( - width: 60, - height: 60, - decoration: index == stackIndex.value - ? const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.all(Radius.circular(6)), - border: Border.fromBorderSide( - BorderSide(color: Colors.white, width: 2), - ), - ) - : const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.all(Radius.circular(6)), - border: null, + return Padding( + key: ValueKey(currentAsset.id), + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + stackIndex.value = index; + ref.read(currentAssetProvider.notifier).set(currentAsset); + }, + child: Container( + width: 60, + height: 60, + decoration: index == stackIndex.value + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Image( + fit: BoxFit.cover, + image: ImmichRemoteImageProvider(assetId: assetId), ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: Image( - fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId), + ), ), ), - ), - ), - ); - }, + ); + }, + ), + ), ), ); } diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index b21d2359ad437..2ea446ea71cfb 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -173,11 +173,15 @@ class GalleryViewerPage extends HookConsumerWidget { } ref.listen(showControlsProvider, (_, show) { - if (show) { + if (show || Platform.isIOS) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + return; } + + // This prevents the bottom bar from "dropping" while the controls are being hidden + Timer(const Duration(milliseconds: 100), () { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + }); }); PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { @@ -359,7 +363,7 @@ class GalleryViewerPage extends HookConsumerWidget { totalAssets: totalAssets, controller: controller, showStack: showStack, - stackIndex: stackIndex.value, + stackIndex: stackIndex, assetIndex: currentIndex, ), ], diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 3d0e24e1926bc..536c7f6303c6a 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -371,7 +371,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { } removeListeners(playerController); playerController.stop().catchError((error) { - log.severe('Error stopping video: $error'); + log.fine('Error stopping video: $error'); }); WakelockPlus.disable(); diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 157232fddc1d6..256141dc7d273 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -28,7 +28,7 @@ import 'package:immich_mobile/pages/editing/edit.page.dart'; class BottomGalleryBar extends ConsumerWidget { final ValueNotifier assetIndex; final bool showStack; - final int stackIndex; + final ValueNotifier stackIndex; final ValueNotifier totalAssets; final PageController controller; final RenderList renderList; @@ -66,10 +66,10 @@ class BottomGalleryBar extends ConsumerWidget { final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false; void removeAssetFromStack() { - if (stackIndex > 0 && showStack && stackId != null) { + if (stackIndex.value > 0 && showStack && stackId != null) { ref .read(assetStackStateProvider(stackId).notifier) - .removeChild(stackIndex - 1); + .removeChild(stackIndex.value - 1); } } @@ -329,7 +329,7 @@ class BottomGalleryBar extends ConsumerWidget { ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + opacity: showControls ? 1.0 : 0.0, child: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient(