diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart new file mode 100644 index 0000000000..224eb838e7 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:video_player/video_player.dart'; +import 'package:immich_mobile/shared/models/store.dart' as store; +import 'package:wakelock_plus/wakelock_plus.dart'; + +/// Provides the initialized video player controller +/// If the asset is local, use the local file +/// Otherwise, use a video player with a URL +ChewieController? useChewieController( + Asset asset, { + EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only( + bottom: 100, + ), + bool showOptions = true, + bool showControlsOnInitialize = false, + bool autoPlay = true, + bool autoInitialize = true, + bool allowFullScreen = false, + bool allowedScreenSleep = false, + bool showControls = true, + Widget? customControls, + Widget? placeholder, + Duration hideControlsTimer = const Duration(seconds: 1), + VoidCallback? onPlaying, + VoidCallback? onPaused, + VoidCallback? onVideoEnded, +}) { + return use( + _ChewieControllerHook( + asset: asset, + placeholder: placeholder, + showOptions: showOptions, + controlsSafeAreaMinimum: controlsSafeAreaMinimum, + autoPlay: autoPlay, + allowFullScreen: allowFullScreen, + customControls: customControls, + hideControlsTimer: hideControlsTimer, + showControlsOnInitialize: showControlsOnInitialize, + showControls: showControls, + autoInitialize: autoInitialize, + allowedScreenSleep: allowedScreenSleep, + onPlaying: onPlaying, + onPaused: onPaused, + onVideoEnded: onVideoEnded, + ), + ); +} + +class _ChewieControllerHook extends Hook { + final Asset asset; + final EdgeInsets controlsSafeAreaMinimum; + final bool showOptions; + final bool showControlsOnInitialize; + final bool autoPlay; + final bool autoInitialize; + final bool allowFullScreen; + final bool allowedScreenSleep; + final bool showControls; + final Widget? customControls; + final Widget? placeholder; + final Duration hideControlsTimer; + final VoidCallback? onPlaying; + final VoidCallback? onPaused; + final VoidCallback? onVideoEnded; + + const _ChewieControllerHook({ + required this.asset, + this.controlsSafeAreaMinimum = const EdgeInsets.only( + bottom: 100, + ), + this.showOptions = true, + this.showControlsOnInitialize = false, + this.autoPlay = true, + this.autoInitialize = true, + this.allowFullScreen = false, + this.allowedScreenSleep = false, + this.showControls = true, + this.customControls, + this.placeholder, + this.hideControlsTimer = const Duration(seconds: 3), + this.onPlaying, + this.onPaused, + this.onVideoEnded, + }); + + @override + createState() => _ChewieControllerHookState(); +} + +class _ChewieControllerHookState + extends HookState { + ChewieController? chewieController; + VideoPlayerController? videoPlayerController; + + @override + void initHook() async { + super.initHook(); + unawaited(_initialize()); + } + + @override + void dispose() { + chewieController?.dispose(); + videoPlayerController?.dispose(); + super.dispose(); + } + + @override + ChewieController? build(BuildContext context) { + return chewieController; + } + + /// Initializes the chewie controller and video player controller + Future _initialize() async { + if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) { + // Use a local file for the video player controller + final file = await hook.asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + videoPlayerController = VideoPlayerController.file(file); + } else { + // Use a network URL for the video player controller + final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint); + final String videoUrl = hook.asset.livePhotoVideoId != null + ? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}' + : '$serverEndpoint/asset/file/${hook.asset.remoteId}'; + + final url = Uri.parse(videoUrl); + final accessToken = store.Store.get(StoreKey.accessToken); + + videoPlayerController = VideoPlayerController.networkUrl( + url, + httpHeaders: {"x-immich-user-token": accessToken}, + ); + } + + videoPlayerController!.addListener(() { + final value = videoPlayerController!.value; + if (value.isPlaying) { + WakelockPlus.enable(); + hook.onPlaying?.call(); + } else if (!value.isPlaying) { + WakelockPlus.disable(); + hook.onPaused?.call(); + } + + if (value.position == value.duration) { + WakelockPlus.disable(); + hook.onVideoEnded?.call(); + } + }); + + await videoPlayerController!.initialize(); + + setState(() { + chewieController = ChewieController( + videoPlayerController: videoPlayerController!, + controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, + showOptions: hook.showOptions, + showControlsOnInitialize: hook.showControlsOnInitialize, + autoPlay: hook.autoPlay, + autoInitialize: hook.autoInitialize, + allowFullScreen: hook.allowFullScreen, + allowedScreenSleep: hook.allowedScreenSleep, + showControls: hook.showControls, + customControls: hook.customControls, + placeholder: hook.placeholder, + hideControlsTimer: hook.hideControlsTimer, + ); + }); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart index 781e84e458..bfc45b8a35 100644 --- a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart +++ b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; -import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; import 'package:video_player/video_player.dart'; class VideoPlayerControls extends ConsumerStatefulWidget { @@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState children: [ if (_displayBufferingIndicator) const Center( - child: ImmichLoadingIndicator(), + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 400), + ), ) else _buildHitArea(), @@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState @override void dispose() { _dispose(); + super.dispose(); } @@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState final oldController = _chewieController; _chewieController = ChewieController.of(context); controller = chewieController.videoPlayerController; + _latestValue = controller.value; if (oldController != chewieController) { _dispose(); @@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState return GestureDetector( onTap: () { - if (_latestValue.isPlaying) { - ref.read(showControlsProvider.notifier).show = false; - } else { + if (!_latestValue.isPlaying) { _playPause(); - ref.read(showControlsProvider.notifier).show = false; } + ref.read(showControlsProvider.notifier).show = false; }, child: CenterPlayButton( backgroundColor: Colors.black54, @@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState } Future _initialize() async { + ref.read(showControlsProvider.notifier).show = false; _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); - controller.addListener(_updateState); _latestValue = controller.value; + controller.addListener(_updateState); if (controller.value.isPlaying || chewieController.autoPlay) { _startHideTimer(); @@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState } void _startHideTimer() { - final hideControlsTimer = chewieController.hideControlsTimer.isNegative - ? ChewieController.defaultHideControlsTimer - : chewieController.hideControlsTimer; + final hideControlsTimer = chewieController.hideControlsTimer; + _hideTimer?.cancel(); _hideTimer = Timer(hideControlsTimer, () { ref.read(showControlsProvider.notifier).show = false; }); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 59dfef8164..a78c085de4 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -704,6 +704,18 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + useEffect( + () { + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + return null; + }, + [], + ); + ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -794,7 +806,9 @@ class GalleryViewerPage extends HookConsumerWidget { minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( - onPlaying: () => isPlayingVideo.value = true, + onPlaying: () { + isPlayingVideo.value = true; + }, onPaused: () => WidgetsBinding.instance.addPostFrameCallback( (_) => isPlayingVideo.value = false, diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index 72aa397f67..0967bf52a7 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -1,23 +1,15 @@ -import 'dart:io'; - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:chewie/chewie.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; -import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; -import 'package:photo_manager/photo_manager.dart'; -import 'package:video_player/video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; @RoutePage() // ignore: must_be_immutable -class VideoViewerPage extends HookConsumerWidget { +class VideoViewerPage extends HookWidget { final Asset asset; final bool isMotionVideo; final Widget? placeholder; @@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { - if (asset.isLocal && asset.livePhotoVideoId == null) { - final AsyncValue videoFile = ref.watch(_fileFamily(asset.local!)); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: videoFile.when( - data: (data) => VideoPlayer( - file: data, - isMotionVideo: false, - onVideoEnded: () {}, - ), - error: (error, stackTrace) => Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ), - loading: () => showDownloadingIndicator - ? const Center(child: ImmichLoadingIndicator()) - : Container(), - ), - ); - } - final downloadAssetStatus = - ref.watch(imageViewerStateProvider).downloadAssetStatus; - final String videoUrl = isMotionVideo - ? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}' - : '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}'; - - return Stack( - children: [ - VideoPlayer( - url: videoUrl, - accessToken: Store.get(StoreKey.accessToken), - isMotionVideo: isMotionVideo, - onVideoEnded: onVideoEnded, - onPaused: onPaused, - onPlaying: onPlaying, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - ), - AnimatedOpacity( - duration: const Duration(milliseconds: 400), - opacity: (downloadAssetStatus == DownloadAssetStatus.loading && - showDownloadingIndicator) - ? 1.0 - : 0.0, - child: SizedBox( - height: context.height, - width: context.width, - child: const Center( - child: ImmichLoadingIndicator(), - ), - ), - ), - ], - ); - } -} - -final _fileFamily = - FutureProvider.family((ref, entity) async { - final file = await entity.file; - if (file == null) { - throw Exception(); - } - return file; -}); - -class VideoPlayer extends StatefulWidget { - final String? url; - final String? accessToken; - final File? file; - final bool isMotionVideo; - final VoidCallback? onVideoEnded; - final Duration hideControlsTimer; - final bool showControls; - - final Function()? onPlaying; - final Function()? onPaused; - - /// The placeholder to show while the video is loading - /// usually, a thumbnail of the video - final Widget? placeholder; - - final bool showDownloadingIndicator; - - const VideoPlayer({ - super.key, - this.url, - this.accessToken, - this.file, - this.onVideoEnded, - required this.isMotionVideo, - this.onPlaying, - this.onPaused, - this.placeholder, - this.hideControlsTimer = const Duration( - seconds: 5, - ), - this.showControls = true, - this.showDownloadingIndicator = true, - }); - - @override - State createState() => _VideoPlayerState(); -} - -class _VideoPlayerState extends State { - late VideoPlayerController videoPlayerController; - ChewieController? chewieController; - - @override - void initState() { - super.initState(); - initializePlayer(); - - videoPlayerController.addListener(() { - if (videoPlayerController.value.isInitialized) { - if (videoPlayerController.value.isPlaying) { - WakelockPlus.enable(); - widget.onPlaying?.call(); - } else if (!videoPlayerController.value.isPlaying) { - WakelockPlus.disable(); - widget.onPaused?.call(); - } - - if (videoPlayerController.value.position == - videoPlayerController.value.duration) { - WakelockPlus.disable(); - widget.onVideoEnded?.call(); - } - } - }); - } - - Future initializePlayer() async { - try { - videoPlayerController = widget.file == null - ? VideoPlayerController.networkUrl( - Uri.parse(widget.url!), - httpHeaders: {"x-immich-user-token": widget.accessToken ?? ""}, - ) - : VideoPlayerController.file(widget.file!); - - await videoPlayerController.initialize(); - _createChewieController(); - setState(() {}); - } catch (e) { - debugPrint("ERROR initialize video player $e"); - } - } - - _createChewieController() { - chewieController = ChewieController( + Widget build(BuildContext context) { + final controller = useChewieController( + asset, controlsSafeAreaMinimum: const EdgeInsets.only( bottom: 100, ), - showOptions: true, - showControlsOnInitialize: false, - videoPlayerController: videoPlayerController, - autoPlay: true, - autoInitialize: true, - allowFullScreen: false, - allowedScreenSleep: false, - showControls: widget.showControls && !widget.isMotionVideo, + placeholder: placeholder, + showControls: showControls && !isMotionVideo, + hideControlsTimer: hideControlsTimer, customControls: const VideoPlayerControls(), - hideControlsTimer: widget.hideControlsTimer, + onPlaying: onPlaying, + onPaused: onPaused, + onVideoEnded: onVideoEnded, + ); + + // Loading + return PopScope( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: Builder( + builder: (context) { + if (controller == null) { + return Stack( + children: [ + if (placeholder != null) placeholder!, + const DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 500), + ), + ], + ); + } + + final size = MediaQuery.of(context).size; + return SizedBox( + height: size.height, + width: size.width, + child: Chewie( + controller: controller, + ), + ); + }, + ), + ), ); } - - @override - void dispose() { - super.dispose(); - videoPlayerController.pause(); - videoPlayerController.dispose(); - chewieController?.dispose(); - } - - @override - Widget build(BuildContext context) { - if (chewieController?.videoPlayerController.value.isInitialized == true) { - return SizedBox( - height: context.height, - width: context.width, - child: Chewie( - controller: chewieController!, - ), - ); - } else { - return SizedBox( - height: context.height, - width: context.width, - child: Center( - child: Stack( - children: [ - if (widget.placeholder != null) widget.placeholder!, - if (widget.showDownloadingIndicator) - const Center( - child: ImmichLoadingIndicator(), - ), - ], - ), - ), - ); - } - } } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index f6968dafe5..16ac5efb0e 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo { void Function()? onPaused, Widget? placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(seconds: 5), + Duration hideControlsTimer = const Duration(milliseconds: 1500), bool showDownloadingIndicator = true, List? children, }) : super( @@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs { this.onPaused, this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), + this.hideControlsTimer = const Duration(milliseconds: 1500), this.showDownloadingIndicator = true, }); diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index afd49adc6a..dd38b050b1 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -171,6 +171,11 @@ class Asset { int? stackCount; + /// Aspect ratio of the asset + @ignore + double? get aspectRatio => + width == null || height == null ? 0 : width! / height!; + /// `true` if this [Asset] is present on the device @ignore bool get isLocal => localId != null; diff --git a/mobile/lib/shared/ui/delayed_loading_indicator.dart b/mobile/lib/shared/ui/delayed_loading_indicator.dart new file mode 100644 index 0000000000..b4d9f4c806 --- /dev/null +++ b/mobile/lib/shared/ui/delayed_loading_indicator.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; + +class DelayedLoadingIndicator extends StatelessWidget { + /// The delay to avoid showing the loading indicator + final Duration delay; + + /// Defaults to using the [ImmichLoadingIndicator] + final Widget? child; + + /// An optional fade in duration to animate the loading + final Duration? fadeInDuration; + + const DelayedLoadingIndicator({ + super.key, + this.delay = const Duration(seconds: 3), + this.child, + this.fadeInDuration, + }); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: fadeInDuration ?? Duration.zero, + child: FutureBuilder( + future: Future.delayed(delay), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return child ?? + const ImmichLoadingIndicator( + key: ValueKey('loading'), + ); + } + + return Container(key: const ValueKey('hiding')); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/views/immich_loading_overlay.dart b/mobile/lib/shared/views/immich_loading_overlay.dart index 85f0123ed9..c600d2a724 100644 --- a/mobile/lib/shared/views/immich_loading_overlay.dart +++ b/mobile/lib/shared/views/immich_loading_overlay.dart @@ -1,7 +1,7 @@ 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/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; final _loadingEntry = OverlayEntry( builder: (context) => SizedBox.square( @@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry( child: DecoratedBox( decoration: BoxDecoration(color: context.colorScheme.surface.withAlpha(200)), - child: const Center(child: ImmichLoadingIndicator()), + child: const Center( + child: DelayedLoadingIndicator( + delay: Duration(seconds: 1), + fadeInDuration: Duration(milliseconds: 400), + ), + ), ), ), ); @@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook> { class _LoadingOverlayState extends HookState, _LoadingOverlay> { - late final _isProcessing = ValueNotifier(false)..addListener(_listener); - OverlayEntry? overlayEntry; + late final _isLoading = ValueNotifier(false)..addListener(_listener); + OverlayEntry? _loadingOverlay; void _listener() { setState(() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_isProcessing.value) { - overlayEntry?.remove(); - overlayEntry = _loadingEntry; + if (_isLoading.value) { + _loadingOverlay?.remove(); + _loadingOverlay = _loadingEntry; Overlay.of(context).insert(_loadingEntry); } else { - overlayEntry?.remove(); - overlayEntry = null; + _loadingOverlay?.remove(); + _loadingOverlay = null; } }); }); @@ -47,17 +52,17 @@ class _LoadingOverlayState @override ValueNotifier build(BuildContext context) { - return _isProcessing; + return _isLoading; } @override void dispose() { - _isProcessing.dispose(); + _isLoading.dispose(); super.dispose(); } @override - Object? get debugValue => _isProcessing.value; + Object? get debugValue => _isLoading.value; @override String get debugLabel => 'useProcessingOverlay<>';