From e0ed1fcecc9d7585952f756e7ea530b187b49ce3 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Aug 2024 14:20:45 -0500 Subject: [PATCH 1/8] add native player library --- mobile/ios/Podfile.lock | 6 ++ .../lib/pages/common/video_viewer.page.dart | 24 ++++--- .../widgets/asset_viewer/video_player.dart | 67 ++++++++++++++----- mobile/pubspec.lock | 8 +++ mobile/pubspec.yaml | 1 + 5 files changed, 81 insertions(+), 25 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 3b361c4e1902f..54fdc74abc54c 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -63,6 +63,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): @@ -113,6 +115,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`) @@ -165,6 +168,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: @@ -206,6 +211,7 @@ SPEC CHECKSUMS: isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 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 573f7277f2e4c..401e0fab73220 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -103,7 +103,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; }); } @@ -148,16 +148,20 @@ class VideoViewerPage extends HookConsumerWidget { ), if (controller != null) SizedBox( - height: size.height, + height: 16 / 9 * size.width, width: size.width, - child: VideoPlayerViewer( - controller: controller, - isMotionVideo: isMotionVideo, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - loopVideo: loopVideo, + 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 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(); + + 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 c9493f6490b72..7f60b4447febf 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1009,6 +1009,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 3db5457c8c1c1..ebe1c0ce2e73f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + native_video_player: ^1.3.1 #image editing packages crop_image: ^1.0.13 From c5cc4930fb6a08e4c6a0088e59ef1682157a464d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Aug 2024 17:30:30 -0500 Subject: [PATCH 2/8] 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 d8ea7cd89b47f..4c6a3ca735296 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -10,6 +10,7 @@ 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/extensions/build_context_extensions.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'; @@ -61,7 +62,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; @@ -352,6 +352,9 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } else { + final useNativePlayer = + asset.isLocal && asset.livePhotoVideoId == null; + return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, @@ -366,19 +369,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 401e0fab73220..573f7277f2e4c 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -103,7 +103,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; }); } @@ -148,20 +148,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, - // ); + 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(); - - Future.delayed(const Duration(milliseconds: 100), () async { - await controller.setVolume(0.5); - }); - } - } catch (e) { - print('Error loading video: $e'); - } - }, + return Chewie( + controller: chewie, ); } } From b18167dd5ea74d5986bd30012e91473cd526e512 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 10 Aug 2024 10:31:10 -0500 Subject: [PATCH 3/8] stateful widget --- .../lib/pages/common/gallery_viewer.page.dart | 13 +- .../common/native_video_viewer.page.dart | 175 +++++++++++------- 2 files changed, 111 insertions(+), 77 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 4c6a3ca735296..0f96b6fb0350e 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -353,8 +353,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, @@ -373,14 +374,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 { + 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'); + } + + return await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + } + + @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() { + final videoInfo = _controller?.videoInfo; + if (videoInfo != null) { + videoWidth = videoInfo.width.toDouble(); + videoHeight = videoInfo.height.toDouble(); + } + setState(() {}); + _controller?.play(); + } + + void _onPlaybackStatusChanged() { + setState(() {}); + } + + void _onPlaybackPositionChanged() { + setState(() {}); + } + + void _onPlaybackSpeedChanged() { + setState(() {}); + } + + void _onPlaybackVolumeChanged() { + setState(() {}); + } + + void _onPlaybackEnded() { + if (isPlaybackLoopEnabled) { + _controller?.play(); + } + } + @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(); - }, + 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 bfd89d162e9e3579f348d1b48eff416631242b6e 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 4/8] refactor: native_video_player --- .../lib/pages/common/gallery_viewer.page.dart | 36 +- .../common/native_video_viewer.page.dart | 316 +++++++++++------- ...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, 247 insertions(+), 231 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 0f96b6fb0350e..fdaa4a0ad2e8d 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,7 +11,6 @@ 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/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'; @@ -352,10 +351,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, @@ -370,24 +365,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(); -} + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); -class NativeVideoViewerPageState extends ConsumerState { - NativeVideoPlayerController? _controller; + 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'; - 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'); + 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(); + } + } + + void onPlaybackReady() { + controller.value?.play(); + } + + void onPlaybackPositionChanged() { + updateVideoPlayback(); + } + + void onPlaybackEnded() { + if (loopVideo) { + controller.value?.play(); + } + } + + Future initController(NativeVideoPlayerController nc) async { + if (controller.value != null) { + return; + } + + controller.value = nc; + + controller.value?.onPlaybackPositionChanged + .addListener(onPlaybackPositionChanged); + controller.value?.onPlaybackStatusChanged + .addListener(onPlaybackPositionChanged); + controller.value?.onPlaybackReady.addListener(onPlaybackReady); + controller.value?.onPlaybackEnded.addListener(onPlaybackEnded); + + final videoSource = await createSource(asset); + controller.value?.loadVideoSource(videoSource); + } + + 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); + }; + }, + [], ); - } - @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 updatePlayback(VideoPlaybackValue value) => + ref.read(videoPlaybackValueProvider.notifier).value = value; - void _onPlaybackReady() { - final videoInfo = _controller?.videoInfo; - if (videoInfo != null) { - videoWidth = videoInfo.width.toDouble(); - videoHeight = videoInfo.height.toDouble(); - } - setState(() {}); - _controller?.play(); - } + final size = MediaQuery.sizeOf(context); - void _onPlaybackStatusChanged() { - setState(() {}); - } - - void _onPlaybackPositionChanged() { - setState(() {}); - } - - void _onPlaybackSpeedChanged() { - setState(() {}); - } - - void _onPlaybackVolumeChanged() { - setState(() {}); - } - - void _onPlaybackEnded() { - if (isPlaybackLoopEnabled) { - _controller?.play(); - } - } - - @override - Widget build(BuildContext context) { - return PopScope( - onPopInvoked: (pop) {}, - child: SizedBox( - height: videoHeight, - width: videoWidth, - child: AspectRatio( - aspectRatio: 16 / 9, - child: NativeVideoPlayerView( - onViewReady: _initController, + 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 7f60b4447febf..e6b18bb1f8df9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1012,10 +1012,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 ebe1c0ce2e73f..282b57a086c6b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,7 +56,10 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme - 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 abe9b50f5839ca5c31e487b25cb38b765f391a28 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 5/8] 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 8cfb88c8dab9588905c50e68086aa105f78022d6 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Aug 2024 15:03:01 -0500 Subject: [PATCH 6/8] 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 e6b18bb1f8df9..3a9c895cef7d6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1746,10 +1746,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 c50cdf68b4dfd863945169232a02a74bd9ee0ddd 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 7/8] 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 3a9c895cef7d6..e6b18bb1f8df9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1746,10 +1746,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 ac3d71eee81f997c246ff6c4438b518d245034a8 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 8/8] fix: handle remote asset orientation --- mobile/lib/entities/asset.entity.g.dart | 141 ++++++++---- 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 +- 6 files changed, 393 insertions(+), 74 deletions(-) diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 8be636efb659b..23bf23604635d 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,64 +57,69 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isTrashed': PropertySchema( + r'isOffline': PropertySchema( id: 8, + name: r'isOffline', + type: IsarType.bool, + ), + r'isTrashed': PropertySchema( + id: 9, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 9, + id: 10, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 10, + id: 11, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 11, + id: 12, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 12, + id: 13, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 13, + id: 14, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 14, + id: 15, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 15, + id: 16, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 16, + id: 17, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 17, + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -239,18 +244,19 @@ void _assetSerialize( writer.writeInt(offsets[5], object.height); writer.writeBool(offsets[6], object.isArchived); writer.writeBool(offsets[7], object.isFavorite); - writer.writeBool(offsets[8], object.isTrashed); - writer.writeString(offsets[9], object.livePhotoVideoId); - writer.writeString(offsets[10], object.localId); - writer.writeLong(offsets[11], object.ownerId); - writer.writeString(offsets[12], object.remoteId); - writer.writeLong(offsets[13], object.stackCount); - writer.writeString(offsets[14], object.stackId); - writer.writeString(offsets[15], object.stackPrimaryAssetId); - writer.writeString(offsets[16], object.thumbhash); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeBool(offsets[8], object.isOffline); + writer.writeBool(offsets[9], object.isTrashed); + writer.writeString(offsets[10], object.livePhotoVideoId); + writer.writeString(offsets[11], object.localId); + writer.writeLong(offsets[12], object.ownerId); + writer.writeString(offsets[13], object.remoteId); + writer.writeLong(offsets[14], object.stackCount); + writer.writeString(offsets[15], object.stackId); + writer.writeString(offsets[16], object.stackPrimaryAssetId); + writer.writeString(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -269,19 +275,20 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - isTrashed: reader.readBoolOrNull(offsets[8]) ?? false, - livePhotoVideoId: reader.readStringOrNull(offsets[9]), - localId: reader.readStringOrNull(offsets[10]), - ownerId: reader.readLong(offsets[11]), - remoteId: reader.readStringOrNull(offsets[12]), - stackCount: reader.readLongOrNull(offsets[13]) ?? 0, - stackId: reader.readStringOrNull(offsets[14]), - stackPrimaryAssetId: reader.readStringOrNull(offsets[15]), - thumbhash: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + isOffline: reader.readBoolOrNull(offsets[8]) ?? false, + isTrashed: reader.readBoolOrNull(offsets[9]) ?? false, + livePhotoVideoId: reader.readStringOrNull(offsets[10]), + localId: reader.readStringOrNull(offsets[11]), + ownerId: reader.readLong(offsets[12]), + remoteId: reader.readStringOrNull(offsets[13]), + stackCount: reader.readLongOrNull(offsets[14]) ?? 0, + stackId: reader.readStringOrNull(offsets[15]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), + thumbhash: reader.readStringOrNull(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -312,27 +319,29 @@ P _assetDeserializeProp

( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readStringOrNull(offset)) as P; + return (reader.readBoolOrNull(offset) ?? false) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readLong(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 12: - return (reader.readStringOrNull(offset)) as P; + return (reader.readLong(offset)) as P; case 13: - return (reader.readLongOrNull(offset) ?? 0) as P; - case 14: return (reader.readStringOrNull(offset)) as P; + case 14: + return (reader.readLongOrNull(offset) ?? 0) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readStringOrNull(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1353,6 +1362,16 @@ extension AssetQueryFilter on QueryBuilder { }); } + QueryBuilder isOfflineEqualTo( + bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isOffline', + value: value, + )); + }); + } + QueryBuilder isTrashedEqualTo( bool value) { return QueryBuilder.apply(this, (query) { @@ -2628,6 +2647,18 @@ extension AssetQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByIsOffline() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.asc); + }); + } + + QueryBuilder sortByIsOfflineDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.desc); + }); + } + QueryBuilder sortByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -2882,6 +2913,18 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByIsOffline() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.asc); + }); + } + + QueryBuilder thenByIsOfflineDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.desc); + }); + } + QueryBuilder thenByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -3078,6 +3121,12 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByIsOffline() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'isOffline'); + }); + } + QueryBuilder distinctByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'isTrashed'); @@ -3214,6 +3263,12 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder isOfflineProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isOffline'); + }); + } + QueryBuilder isTrashedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'isTrashed'); 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 016f6d71260d0..2c63f91cff258 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);