From 338e5a8e5cd3c63ff1ca65bf6832ad4436a7e9c1 Mon Sep 17 00:00:00 2001 From: Mert Alev <38161441+Ulbert@users.noreply.github.com> Date: Sat, 2 Nov 2024 15:58:59 -0400 Subject: [PATCH] wip separate widget --- .../lib/pages/common/gallery_viewer.page.dart | 6 +- .../lib/pages/common/native_video_loader.dart | 169 +++++++++ .../common/native_video_viewer.page.dart | 356 ++++++++++-------- mobile/lib/utils/hooks/interval_hook.dart | 18 + mobile/lib/widgets/memories/memory_card.dart | 5 +- 5 files changed, 385 insertions(+), 169 deletions(-) create mode 100644 mobile/lib/pages/common/native_video_loader.dart create mode 100644 mobile/lib/utils/hooks/interval_hook.dart diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index c70733dba8..bf76d396f0 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -12,7 +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/native_video_loader.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'; @@ -366,8 +366,8 @@ class GalleryViewerPage extends HookConsumerWidget { maxScale: 1.0, minScale: 1.0, basePosition: Alignment.center, - child: NativeVideoViewerPage( - key: ValueKey(a), + child: NativeVideoLoader( + key: ValueKey(a.id), asset: a, isMotionVideo: a.livePhotoVideoId != null, loopVideo: shouldLoopVideo.value, diff --git a/mobile/lib/pages/common/native_video_loader.dart b/mobile/lib/pages/common/native_video_loader.dart new file mode 100644 index 0000000000..91927ac530 --- /dev/null +++ b/mobile/lib/pages/common/native_video_loader.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:flutter/material.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/entities/store.entity.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.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:logging/logging.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class NativeVideoLoader extends HookConsumerWidget { + final Asset asset; + final bool isMotionVideo; + final Widget placeholder; + final bool showControls; + final Duration hideControlsTimer; + final bool loopVideo; + + const NativeVideoLoader({ + super.key, + required this.asset, + required this.placeholder, + this.isMotionVideo = false, + this.showControls = true, + this.hideControlsTimer = const Duration(seconds: 5), + this.loopVideo = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // fast path for aspect ratio + final initAspectRatio = useMemoized( + () { + if (asset.exifInfo == null) { + return null; + } + + final width = asset.orientatedWidth?.toDouble(); + final height = asset.orientatedHeight?.toDouble(); + return width != null && height != null && width > 0 && height > 0 + ? width / height + : null; + }, + ); + + final localEntity = useMemoized( + () => asset.localId != null ? AssetEntity.fromId(asset.localId!) : null, + ); + Future calculateAspectRatio() async { + 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(); + } + + if (orientatedWidth != null && + orientatedHeight != null && + orientatedWidth > 0 && + orientatedHeight > 0) { + return orientatedWidth / orientatedHeight; + } + + return 1.0; + } + + final aspectRatioFuture = useMemoized( + () => initAspectRatio == null ? calculateAspectRatio() : null, + ); + + final log = Logger('NativeVideoLoader'); + log.info('Building NativeVideoLoader'); + + Future createLocalSource() async { + log.info('Loading video from local storage'); + final entity = await localEntity; + if (entity == null) { + throw Exception('No entity found for the video'); + } + + final file = await entity.file; + if (file == null) { + throw Exception('No file found for the video'); + } + + return await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + } + + Future createRemoteSource() async { + 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'; + + return await VideoSource.init( + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), + ); + } + + Future createSource(Asset asset) async { + if (asset.isLocal && asset.livePhotoVideoId == null) { + return createLocalSource(); + } + + return createRemoteSource(); + } + + 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: FutureBuilder( + key: ValueKey(asset.id), + future: aspectRatioFuture, + initialData: initAspectRatio, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return placeholder; + } + + return NativeVideoViewerPage( + key: ValueKey(asset.id), + videoSource: createSource(asset), + duration: asset.duration, + aspectRatio: snapshot.data as double, + isMotionVideo: isMotionVideo, + hideControlsTimer: hideControlsTimer, + loopVideo: loopVideo, + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index ec0458dfff..478f084040 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -3,32 +3,47 @@ import 'dart:async'; import 'package:flutter/material.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/entities/store.entity.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/services/api.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'; +// @override +// void dispose() { +// bufferingTimer.value.cancel(); +// 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 +// } +// super.dispose(); +// } + class NativeVideoViewerPage extends HookConsumerWidget { - final Asset asset; + final Future videoSource; + final double aspectRatio; + final Duration duration; final bool isMotionVideo; - final Widget? placeholder; final bool showControls; final Duration hideControlsTimer; final bool loopVideo; const NativeVideoViewerPage({ super.key, - required this.asset, + required this.videoSource, + required this.aspectRatio, + required this.duration, this.isMotionVideo = false, - this.placeholder, this.showControls = true, this.hideControlsTimer = const Duration(seconds: 5), this.loopVideo = false, @@ -36,79 +51,53 @@ class NativeVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final controller = useState(null); + final controller = useRef(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 log = Logger('NativeVideoViewerPage'); + log.info('Building NativeVideoViewerPage'); void checkIfBuffering([Timer? timer]) { if (!context.mounted) { - timer?.cancel(); - return; + return timer?.cancel(); } + 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 - final bufferingTimer = useRef( - Timer.periodic(const Duration(seconds: 5), checkIfBuffering), - ); - - Future createSource(Asset asset) async { - if (asset.isLocal && asset.livePhotoVideoId == null) { - final entity = await AssetEntity.fromId(asset.localId!); - 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, - ); - } else { - final assetWithExif = - await ref.read(assetServiceProvider).loadExif(asset); - - width.value = assetWithExif.orientatedWidth?.toDouble() ?? width.value; - height.value = - assetWithExif.orientatedHeight?.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(), - ); - } - } + 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) { + log.info('No controller to seek to'); + return; + } + try { if (mute) { - controller.value?.setVolume(0.0); + log.info('Muting video'); + playerController.setVolume(0.0); + log.info('Muted video'); } else { - controller.value?.setVolume(0.7); + log.info('Unmuting video'); + playerController.setVolume(0.7); + log.info('Unmuted video'); } - } catch (_) { + } catch (error) { + log.severe('Error setting volume: $error'); // Consume error from the controller } }); @@ -116,16 +105,21 @@ class NativeVideoViewerPage extends HookConsumerWidget { // When the position changes, seek to the position ref.listen(videoPlayerControlsProvider.select((value) => value.position), (_, position) { - if (controller.value == null) { + final playerController = controller.value; + if (playerController == null) { + log.info('No controller to seek to'); // No seeeking if there is no video return; } // Find the position to seek to - final Duration seek = asset.duration * (position / 100.0); + final Duration seek = duration * (position / 100.0); try { - controller.value?.seekTo(seek.inSeconds); - } catch (_) { + log.info('Seeking to position: ${seek.inSeconds}'); + playerController.seekTo(seek.inSeconds); + log.info('Seeked to position: ${seek.inSeconds}'); + } catch (error) { + log.severe('Error seeking to position $position: $error'); // Consume error from the controller } }); @@ -135,108 +129,172 @@ class NativeVideoViewerPage extends HookConsumerWidget { (_, pause) { try { if (pause) { + log.info('Pausing video'); controller.value?.pause(); + log.info('Paused video'); } else { + log.info('Playing video'); controller.value?.play(); + log.info('Played video'); } - } catch (_) { + } catch (error) { + log.severe('Error pausing or playing video: $error'); // Consume error from the controller } }); - void updateVideoPlayback() { - if (controller.value == 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; - } - 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() { try { + log.info('onPlaybackReady: Playing video'); controller.value?.play(); controller.value?.setVolume(0.9); - } catch (_) { + log.info('onPlaybackReady: Played video'); + } catch (error) { + log.severe('Error playing video: $error'); // Consume error from the controller } } void onPlaybackPositionChanged() { - updateVideoPlayback(); + if (controller.value == null || !context.mounted) { + log.info('No controller to update'); + return; + } + + log.info('Updating video playback'); + final videoPlayback = + VideoPlaybackValue.fromNativeController(controller.value!); + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + log.info('Updated video playback'); + + // Check if the video is buffering + if (videoPlayback.state == VideoPlaybackState.playing) { + log.info('Updating video: checking if playing video is buffering'); + isBuffering.value = + lastVideoPosition.value == videoPlayback.position.inSeconds; + lastVideoPosition.value = videoPlayback.position.inSeconds; + log.info('Updating playing video position'); + } else { + log.info('Updating video: video is not playing'); + isBuffering.value = false; + lastVideoPosition.value = -1; + log.info('Updated non-playing video position'); + } + final state = videoPlayback.state; + + // Enable the WakeLock while the video is playing + if (state == VideoPlaybackState.playing) { + log.info('Syncing with the controls playing'); + // Sync with the controls playing + WakelockPlus.enable(); + log.info('Synced with the controls playing'); + } else { + log.info('Syncing with the controls pause'); + // Sync with the controls pause + WakelockPlus.disable(); + log.info('Synced with the controls pause'); + } } void onPlaybackEnded() { try { + log.info('onPlaybackEnded: Video ended'); if (loopVideo) { + log.info('onPlaybackEnded: Looping video'); controller.value?.play(); + log.info('onPlaybackEnded: Looped video'); } - } catch (_) { + } catch (error) { + log.severe('Error looping video: $error'); // Consume error from the controller } } Future initController(NativeVideoPlayerController nc) async { if (controller.value != null) { + log.info('initController: Controller already initialized'); return; } + log.info('initController: adding onPlaybackPositionChanged listener'); nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); + log.info('initController: added onPlaybackPositionChanged listener'); + + log.info('initController: adding onPlaybackStatusChanged listener'); nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); + log.info('initController: added onPlaybackStatusChanged listener'); + + log.info('initController: adding onPlaybackReady listener'); nc.onPlaybackReady.addListener(onPlaybackReady); + log.info('initController: added onPlaybackReady listener'); + + log.info('initController: adding onPlaybackEnded listener'); nc.onPlaybackEnded.addListener(onPlaybackEnded); + log.info('initController: added onPlaybackEnded listener'); - final videoSource = await createSource(asset); - nc.loadVideoSource(videoSource); + log.info('initController: loading video source'); + nc.loadVideoSource(await videoSource); + log.info('initController: loaded video source'); + log.info('initController: setting controller'); controller.value = nc; + log.info('initController: set controller'); Timer(const Duration(milliseconds: 200), checkIfBuffering); } useEffect( () { - Future.microtask( - () => ref.read(videoPlayerControlsProvider.notifier).reset(), - ); + log.info('useEffect: resetting video player controls'); + ref.read(videoPlayerControlsProvider.notifier).reset(); + log.info('useEffect: resetting video player controls'); if (isMotionVideo) { // ignore: prefer-extracting-callbacks - Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; - }); + log.info('useEffect: disabled showing video player controls'); + ref.read(showControlsProvider.notifier).show = false; + log.info('useEffect: disabled showing video player controls'); } return () { - bufferingTimer.value.cancel(); try { - controller.value?.onPlaybackPositionChanged + final playerController = controller.value; + if (playerController == null) { + log.info('No controller to dispose'); + return; + } + log.info('Removing onPlaybackPositionChanged listener'); + playerController.onPlaybackPositionChanged .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackStatusChanged + log.info('Removed onPlaybackPositionChanged listener'); + + log.info('Removing onPlaybackStatusChanged listener'); + playerController.onPlaybackStatusChanged .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackReady.removeListener(onPlaybackReady); - controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); - controller.value?.stop(); - } catch (_) { + log.info('Removed onPlaybackStatusChanged listener'); + + log.info('Removing onPlaybackReady listener'); + playerController.onPlaybackReady.removeListener(onPlaybackReady); + log.info('Removed onPlaybackReady listener'); + + log.info('Removing onPlaybackEnded listener'); + playerController.onPlaybackEnded.removeListener(onPlaybackEnded); + log.info('Removed onPlaybackEnded listener'); + + Future.microtask(() async { + log.info('Stopping video'); + await playerController.stop(); + log.info('Stopped video'); + + log.info('Disabling wakelock'); + await WakelockPlus.disable(); + log.info('Disabled wakelock'); + }); + + log.info('Disposing controller'); + controller.value = null; + log.info('Disposed controller'); + } catch (error) { + log.severe('Error during useEffect cleanup: $error'); // Consume error from the controller } }; @@ -244,63 +302,33 @@ 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( - 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: [ + Center( + child: AspectRatio( + aspectRatio: aspectRatio, + child: NativeVideoPlayerView( + onViewReady: initController, ), ), ), - ), + if (showControls) + Center( + child: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, + ), + ), + // Visibility( + // visible: controller.value == null, + // child: const Positioned.fill( + // child: Center( + // child: DelayedLoadingIndicator( + // fadeInDuration: Duration(milliseconds: 500), + // ), + // ), + // ), + // ), + ], ); } } diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart new file mode 100644 index 0000000000..0c346065f7 --- /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/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 138ee6debb..39b5058646 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.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_loader.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,8 +69,8 @@ class MemoryCard extends StatelessWidget { } else { return Hero( tag: 'memory-${asset.id}', - child: NativeVideoViewerPage( - key: ValueKey(asset), + child: NativeVideoLoader( + key: ValueKey(asset.id), asset: asset, placeholder: SizedBox.expand( child: ImmichImage(