From f151e6cead52bd007c328d253a5820d29ee7f25c Mon Sep 17 00:00:00 2001 From: Marty Fuhry Date: Fri, 1 Mar 2024 15:37:37 -0500 Subject: [PATCH] Refactors video player controller format fixing video format Working format --- .../hooks/chewiew_controller_hook.dart | 5 +- .../video_player_controls_provider.dart | 50 ++++- .../video_player_value_provider.dart | 71 +++++- .../asset_viewer/ui/bottom_gallery_bar.dart | 58 ++--- .../ui/custom_video_player_controls.dart | 102 +++++++++ .../asset_viewer/ui/video_controls.dart | 122 +++++----- .../ui/video_player_controls.dart | 209 ------------------ .../asset_viewer/views/gallery_viewer.dart | 71 +++--- .../asset_viewer/views/video_viewer_page.dart | 113 ++++++++-- .../lib/modules/memories/ui/memory_card.dart | 1 + mobile/lib/shared/ui/hooks/timer_hook.dart | 48 ++++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 13 files changed, 488 insertions(+), 365 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart delete mode 100644 mobile/lib/modules/asset_viewer/ui/video_player_controls.dart create mode 100644 mobile/lib/shared/ui/hooks/timer_hook.dart diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart index a561d52aeb..7408606584 100644 --- a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart +++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart @@ -96,14 +96,17 @@ class _ChewieControllerHookState @override void initHook() async { + print('CHEWIE CONTROLLER > creating chewie $hashCode'); super.initHook(); _initialize().whenComplete(() => setState(() {})); } @override void dispose() { - chewieController?.dispose(); + print('CHEWIE CONTROLLER > disposing chewie $hashCode'); + videoPlayerController?.pause(); videoPlayerController?.dispose(); + chewieController?.dispose(); super.dispose(); } diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart index b73824f864..0821600a10 100644 --- a/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart @@ -1,10 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; class VideoPlaybackControls { - VideoPlaybackControls({required this.position, required this.mute}); + VideoPlaybackControls({ + required this.position, + required this.mute, + required this.pause, + }); final double position; final bool mute; + final bool pause; } final videoPlayerControlsProvider = @@ -17,6 +22,7 @@ class VideoPlayerControls extends StateNotifier { : super( VideoPlaybackControls( position: 0, + pause: false, mute: false, ), ); @@ -33,14 +39,50 @@ class VideoPlayerControls extends StateNotifier { bool get mute => state.mute; set position(double value) { - state = VideoPlaybackControls(position: value, mute: state.mute); + state = VideoPlaybackControls( + position: value, + mute: state.mute, + pause: state.pause, + ); } set mute(bool value) { - state = VideoPlaybackControls(position: state.position, mute: value); + state = VideoPlaybackControls( + position: state.position, + mute: value, + pause: state.pause, + ); } void toggleMute() { - state = VideoPlaybackControls(position: state.position, mute: !state.mute); + state = VideoPlaybackControls( + position: state.position, + mute: !state.mute, + pause: state.pause, + ); + } + + void pause() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: true, + ); + } + + void play() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: false, + ); + } + + void togglePlay() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: !state.pause, + ); } } diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart index 66f9389a09..ebdf739ef0 100644 --- a/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart @@ -1,10 +1,65 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:video_player/video_player.dart'; + +enum VideoPlaybackState { + initializing, + paused, + playing, + buffering, + completed, +} class VideoPlaybackValue { - VideoPlaybackValue({required this.position, required this.duration}); - + /// The current position of the video final Duration position; + + /// The total duration of the video final Duration duration; + + /// The current state of the video playback + final VideoPlaybackState state; + + /// The volume of the video + final double volume; + + VideoPlaybackValue({ + required this.position, + required this.duration, + required this.state, + required this.volume, + }); + + factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { + final video = controller?.value; + late VideoPlaybackState s; + if (video == null) { + s = VideoPlaybackState.initializing; + } else if (video.isCompleted) { + s = VideoPlaybackState.completed; + } else if (video.isPlaying) { + s = VideoPlaybackState.playing; + } else if (video.isBuffering) { + s = VideoPlaybackState.buffering; + } else { + s = VideoPlaybackState.paused; + } + + return VideoPlaybackValue( + position: video?.position ?? Duration.zero, + duration: video?.duration ?? Duration.zero, + state: s, + volume: video?.volume ?? 0.0, + ); + } + + factory VideoPlaybackValue.uninitialized() { + return VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, + ); + } } final videoPlaybackValueProvider = @@ -15,10 +70,7 @@ final videoPlaybackValueProvider = class VideoPlaybackValueState extends StateNotifier { VideoPlaybackValueState(this.ref) : super( - VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - ), + VideoPlaybackValue.uninitialized(), ); final Ref ref; @@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier { } set position(Duration value) { - state = VideoPlaybackValue(position: value, duration: state.duration); + state = VideoPlaybackValue( + position: value, + duration: state.duration, + state: state.state, + volume: state.volume, + ); } } diff --git a/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart index e90062b5fe..a7d5e4e71c 100644 --- a/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart @@ -10,10 +10,12 @@ import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provide import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; -import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; -import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart'; +import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; @@ -21,6 +23,8 @@ class BottomGalleryBar extends ConsumerWidget { final Asset asset; final bool showStack; final int stackIndex; + final int totalAssets; + final bool showVideoPlayerControls; final PageController controller; const BottomGalleryBar({ @@ -29,6 +33,8 @@ class BottomGalleryBar extends ConsumerWidget { required this.stackIndex, required this.asset, required this.controller, + required this.totalAssets, + required this.showVideoPlayerControls, }); @override @@ -40,7 +46,12 @@ class BottomGalleryBar extends ConsumerWidget { : []; final stackElements = showStack ? [asset, ...stack] : []; bool isParent = stackIndex == -1 || stackIndex == 0; - + final navStack = AutoRouter.of(context).stackData; + final isTrashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + final isFromTrash = isTrashEnabled && + navStack.length > 2 && + navStack.elementAt(navStack.length - 2).name == TrashRoute.name; // !!!! itemsList and actionlist should always be in sync final itemsList = [ BottomNavigationBarItem( @@ -87,11 +98,10 @@ class BottomGalleryBar extends ConsumerWidget { ref .read(assetStackStateProvider(asset).notifier) .removeChild(stackIndex - 1); - stackIndex.value = stackIndex.value - 1; } } - void handleDelete(Asset deleteAsset) async { + void handleDelete() async { // Cannot delete readOnly / external assets. They are handled through library offline jobs if (asset.isReadOnly) { ImmichToast.show( @@ -104,7 +114,7 @@ class BottomGalleryBar extends ConsumerWidget { } Future onDelete(bool force) async { final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( - {deleteAsset}, + {asset}, force: force, ); if (isDeleted && isParent) { @@ -127,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget { final isDeleted = await onDelete(false); if (isDeleted) { // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && deleteAsset.isRemote && isParent) { + if (context.mounted && asset.isRemote && isParent) { ImmichToast.show( durationInSecond: 1, context: context, @@ -178,7 +188,7 @@ class BottomGalleryBar extends ConsumerWidget { .read(assetStackServiceProvider) .updateStackParent( asset, - stackElements.elementAt(stackIndex.value), + stackElements.elementAt(stackIndex), ); ctx.pop(); context.popRoute(); @@ -213,7 +223,7 @@ class BottomGalleryBar extends ConsumerWidget { await ref.read(assetStackServiceProvider).updateStack( asset, childrenToRemove: [ - stackElements.elementAt(stackIndex.value), + stackElements.elementAt(stackIndex), ], ); removeAssetFromStack(); @@ -273,21 +283,6 @@ class BottomGalleryBar extends ConsumerWidget { removeAssetFromStack(); } - handleUpload(Asset asset) { - showDialog( - context: context, - builder: (BuildContext _) { - return UploadDialog( - onUpload: () { - ref - .read(manualUploadProvider.notifier) - .uploadAssets(context, [asset]); - }, - ); - }, - ); - } - handleDownload() { if (asset.isLocal) { return; @@ -323,17 +318,10 @@ class BottomGalleryBar extends ConsumerWidget { opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Column( children: [ - if (stack.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: 10, - bottom: 30, - ), - child: SizedBox( - height: 40, - child: buildStackedChildren(), - ), - ), + Visibility( + visible: showVideoPlayerControls, + child: const VideoControls(), + ), BottomNavigationBar( backgroundColor: Colors.black.withOpacity(0.4), unselectedIconTheme: const IconThemeData(color: Colors.white), diff --git a/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart new file mode 100644 index 0000000000..aa78f9f86e --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +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/delayed_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart'; + +class CustomVideoPlayerControls extends HookConsumerWidget { + final Duration hideTimerDuration; + + const CustomVideoPlayerControls({ + super.key, + this.hideTimerDuration = const Duration(seconds: 3), + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // A timer to hide the controls + final hideTimer = useTimer( + hideTimerDuration, + () { + ref.read(showControlsProvider.notifier).show = false; + }, + ); + + final showBuffering = useState(false); + final VideoPlaybackState state = + ref.watch(videoPlaybackValueProvider).state; + + /// Shows the controls and starts the timer to hide them + void showControlsAndStartHideTimer() { + hideTimer.reset(); + ref.read(showControlsProvider.notifier).show = true; + } + + // When we mute, show the controls + ref.listen(videoPlayerControlsProvider.select((v) => v.mute), + (previous, next) { + showControlsAndStartHideTimer(); + }); + + // When we change position, show or hide timer + ref.listen(videoPlayerControlsProvider.select((v) => v.position), + (previous, next) { + showControlsAndStartHideTimer(); + }); + + ref.listen(videoPlaybackValueProvider.select((value) => value.state), + (_, state) { + // Show buffering + showBuffering.value = state == VideoPlaybackState.buffering; + + // Synchronize player with video state + if (state == VideoPlaybackState.playing) { + ref.read(videoPlayerControlsProvider.notifier).play(); + } else if (state == VideoPlaybackState.paused) { + ref.read(videoPlayerControlsProvider.notifier).pause(); + } + }); + + /// Toggles between playing and pausing depending on the state of the video + void togglePlay() { + showControlsAndStartHideTimer(); + ref.read(videoPlayerControlsProvider.notifier).togglePlay(); + } + + return GestureDetector( + onTap: () => showControlsAndStartHideTimer(), + child: AbsorbPointer( + absorbing: !ref.watch(showControlsProvider), + child: Stack( + children: [ + if (showBuffering.value) + const Center( + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 400), + ), + ), + GestureDetector( + onTap: () { + if (state != VideoPlaybackState.playing) { + togglePlay(); + } + }, + child: CenterPlayButton( + backgroundColor: Colors.black54, + iconColor: Colors.white, + isFinished: state == VideoPlaybackState.completed, + isPlaying: state == VideoPlaybackState.playing, + show: ref.watch(showControlsProvider), + onPressed: togglePlay, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_controls.dart index a05be240c5..45a9372099 100644 --- a/mobile/lib/modules/asset_viewer/ui/video_controls.dart +++ b/mobile/lib/modules/asset_viewer/ui/video_controls.dart @@ -12,72 +12,78 @@ class VideoControls extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final player = ref.watch(videoPlaybackValueProvider); - print('player is $player'); - final duration = player.duration; - final position = player.position; + final duration = + ref.watch(videoPlaybackValueProvider.select((v) => v.duration)); + final position = + ref.watch(videoPlaybackValueProvider.select((v) => v.position)); return AnimatedOpacity( opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, duration: const Duration(milliseconds: 100), - child: Container( - color: Colors.black.withOpacity(0.4), - child: Padding( - padding: MediaQuery.of(context).orientation == Orientation.portrait - ? const EdgeInsets.symmetric(horizontal: 12.0) - : const EdgeInsets.symmetric(horizontal: 64.0), - child: Row( - children: [ - Text( - _formatDuration(position), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, + child: OrientationBuilder( + builder: (context, orientation) => Container( + padding: EdgeInsets.symmetric( + horizontal: orientation == Orientation.portrait ? 12.0 : 64.0, + ), + color: Colors.black.withOpacity(0.4), + child: Padding( + padding: MediaQuery.of(context).orientation == Orientation.portrait + ? const EdgeInsets.symmetric(horizontal: 12.0) + : const EdgeInsets.symmetric(horizontal: 64.0), + child: Row( + children: [ + Text( + _formatDuration(position), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), ), - ), - Expanded( - child: Slider( - value: player.duration == Duration.zero - ? 0.0 - : min( - player.position.inMicroseconds / - player.duration.inMicroseconds * - 100, - 100, - ), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: Colors.white.withOpacity(0.75), - onChanged: (position) { - ref.read(videoPlayerControlsProvider.notifier).position = - position; - }, + Expanded( + child: Slider( + value: duration == Duration.zero + ? 0.0 + : min( + position.inMicroseconds / + duration.inMicroseconds * + 100, + 100, + ), + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: Colors.white.withOpacity(0.75), + onChanged: (position) { + ref.read(videoPlayerControlsProvider.notifier).position = + position; + }, + ), ), - ), - Text( - _formatDuration(duration), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, + Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), ), - ), - IconButton( - icon: Icon( - ref.watch( - videoPlayerControlsProvider.select((value) => value.mute), - ) - ? Icons.volume_off - : Icons.volume_up, + IconButton( + icon: Icon( + ref.watch( + videoPlayerControlsProvider.select((value) => value.mute), + ) + ? Icons.volume_off + : Icons.volume_up, + ), + onPressed: () => ref + .read(videoPlayerControlsProvider.notifier) + .toggleMute(), + color: Colors.white, ), - onPressed: () => - ref.read(videoPlayerControlsProvider.notifier).toggleMute(), - color: Colors.white, - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart deleted file mode 100644 index bfc45b8a35..0000000000 --- a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'dart:async'; - -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; -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/delayed_loading_indicator.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPlayerControls extends ConsumerStatefulWidget { - const VideoPlayerControls({ - super.key, - }); - - @override - VideoPlayerControlsState createState() => VideoPlayerControlsState(); -} - -class VideoPlayerControlsState extends ConsumerState - with SingleTickerProviderStateMixin { - late VideoPlayerController controller; - late VideoPlayerValue _latestValue; - bool _displayBufferingIndicator = false; - double? _latestVolume; - Timer? _hideTimer; - - ChewieController? _chewieController; - ChewieController get chewieController => _chewieController!; - - @override - Widget build(BuildContext context) { - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, value) { - _mute(value); - _cancelAndRestartTimer(); - }); - - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { - _seekTo(position); - _cancelAndRestartTimer(); - }); - - if (_latestValue.hasError) { - return chewieController.errorBuilder?.call( - context, - chewieController.videoPlayerController.value.errorDescription!, - ) ?? - const Center( - child: Icon( - Icons.error, - color: Colors.white, - size: 42, - ), - ); - } - - return GestureDetector( - onTap: () => _cancelAndRestartTimer(), - child: AbsorbPointer( - absorbing: !ref.watch(showControlsProvider), - child: Stack( - children: [ - if (_displayBufferingIndicator) - const Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 400), - ), - ) - else - _buildHitArea(), - ], - ), - ), - ); - } - - @override - void dispose() { - _dispose(); - - super.dispose(); - } - - void _dispose() { - controller.removeListener(_updateState); - _hideTimer?.cancel(); - } - - @override - void didChangeDependencies() { - final oldController = _chewieController; - _chewieController = ChewieController.of(context); - controller = chewieController.videoPlayerController; - _latestValue = controller.value; - - if (oldController != chewieController) { - _dispose(); - _initialize(); - } - - super.didChangeDependencies(); - } - - Widget _buildHitArea() { - final bool isFinished = _latestValue.position >= _latestValue.duration; - - return GestureDetector( - onTap: () { - if (!_latestValue.isPlaying) { - _playPause(); - } - ref.read(showControlsProvider.notifier).show = false; - }, - child: CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: isFinished, - isPlaying: controller.value.isPlaying, - show: ref.watch(showControlsProvider), - onPressed: _playPause, - ), - ); - } - - void _cancelAndRestartTimer() { - _hideTimer?.cancel(); - _startHideTimer(); - ref.read(showControlsProvider.notifier).show = true; - } - - Future _initialize() async { - ref.read(showControlsProvider.notifier).show = false; - _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); - - _latestValue = controller.value; - controller.addListener(_updateState); - - if (controller.value.isPlaying || chewieController.autoPlay) { - _startHideTimer(); - } - } - - void _playPause() { - final isFinished = _latestValue.position >= _latestValue.duration; - - setState(() { - if (controller.value.isPlaying) { - ref.read(showControlsProvider.notifier).show = true; - _hideTimer?.cancel(); - controller.pause(); - } else { - _cancelAndRestartTimer(); - - if (!controller.value.isInitialized) { - controller.initialize().then((_) { - controller.play(); - }); - } else { - if (isFinished) { - controller.seekTo(Duration.zero); - } - controller.play(); - } - } - }); - } - - void _startHideTimer() { - final hideControlsTimer = chewieController.hideControlsTimer; - _hideTimer?.cancel(); - _hideTimer = Timer(hideControlsTimer, () { - ref.read(showControlsProvider.notifier).show = false; - }); - } - - void _updateState() { - if (!mounted) return; - - _displayBufferingIndicator = controller.value.isBuffering; - - setState(() { - _latestValue = controller.value; - ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue( - position: _latestValue.position, - duration: _latestValue.duration, - ); - }); - } - - void _mute(bool mute) { - if (mute) { - _latestVolume = controller.value.volume; - controller.setVolume(0); - } else { - controller.setVolume(_latestVolume ?? 0.5); - } - } - - void _seekTo(double position) { - final Duration pos = controller.value.duration * (position / 100.0); - if (pos != controller.value.position) { - controller.seekTo(pos); - } - } -} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 95b69aa4c7..f7c43682f1 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -2,12 +2,10 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui' as ui; -import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; @@ -17,7 +15,6 @@ import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provi import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; @@ -27,14 +24,11 @@ import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.da import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; -import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; -import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; @@ -74,16 +68,10 @@ class GalleryViewerPage extends HookConsumerWidget { final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isZoomed = useState(false); final isPlayingMotionVideo = useState(false); - final isPlayingVideo = useState(false); Offset? localPosition; final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); - final isTrashEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final navStack = AutoRouter.of(context).stackData; - final isFromTrash = isTrashEnabled && - navStack.length > 2 && - navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + final stackIndex = useState(-1); final stack = showStack && currentAsset.stackChildrenCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) @@ -102,7 +90,6 @@ class GalleryViewerPage extends HookConsumerWidget { .map((e) => e.isarId) .contains(asset.ownerId); - // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page ref.listen(currentAssetProvider, (_, __) {}); useEffect( @@ -223,6 +210,21 @@ class GalleryViewerPage extends HookConsumerWidget { } } + handleUpload(Asset asset) { + showDialog( + context: context, + builder: (BuildContext _) { + return UploadDialog( + onUpload: () { + ref + .read(manualUploadProvider.notifier) + .uploadAssets(context, [asset]); + }, + ); + }, + ); + } + buildAppBar() { return IgnorePointer( ignoring: !ref.watch(showControlsProvider), @@ -238,8 +240,7 @@ class GalleryViewerPage extends HookConsumerWidget { asset: asset, onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, - onUploadPressed: - asset.isLocal ? () => handleUpload(asset) : null, + onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, onDownloadPressed: asset.isLocal ? null : () => @@ -258,10 +259,6 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - // TODO: Migrate to a custom bottom bar and handle long press to delete - Widget buildBottomBar() { - } - useEffect( () { if (ref.read(showControlsProvider)) { @@ -297,11 +294,16 @@ class GalleryViewerPage extends HookConsumerWidget { } }); - Widget buildStackedChildren() { + Widget buildStackedChildren() { return ListView.builder( shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 10, + right: 10, + bottom: 30, + ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; return Padding( @@ -434,13 +436,7 @@ class GalleryViewerPage extends HookConsumerWidget { minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( - onPlaying: () { - isPlayingVideo.value = true; - }, - onPaused: () => - WidgetsBinding.instance.addPostFrameCallback( - (_) => isPlayingVideo.value = false, - ), + key: ValueKey(a), asset: a, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( @@ -472,9 +468,24 @@ class GalleryViewerPage extends HookConsumerWidget { right: 0, child: Column( children: [ - buildStackedChildren(), - BottomGalleryBar( + Visibility( + visible: stack.isNotEmpty, + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), ), + BottomGalleryBar( + totalAssets: totalAssets, + controller: controller, + showStack: showStack, + stackIndex: stackIndex.value, + asset: asset, + showVideoPlayerControls: + !asset.isImage && !isPlayingMotionVideo.value, + ), + ], + ), ), ], ), 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 0708c5e3b6..834918785b 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -2,15 +2,18 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +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/custom_video_player_controls.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; @RoutePage() // ignore: must_be_immutable -class VideoViewerPage extends HookWidget { +class VideoViewerPage extends HookConsumerWidget { final Asset asset; final bool isMotionVideo; final Widget? placeholder; @@ -35,25 +38,108 @@ class VideoViewerPage extends HookWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final controller = useChewieController( asset, controlsSafeAreaMinimum: const EdgeInsets.only( bottom: 100, ), placeholder: SizedBox.expand(child: placeholder), + customControls: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, + ), showControls: showControls && !isMotionVideo, hideControlsTimer: hideControlsTimer, - customControls: const VideoPlayerControls(), onPlaying: onPlaying, onPaused: onPaused, onVideoEnded: onVideoEnded, ); - // Loading + // The last volume of the video used when mute is toggled + final lastVolume = useState(0.0); + + // When the volume changes, set the volume + ref.listen(videoPlayerControlsProvider.select((value) => value.mute), + (_, mute) { + if (mute) { + controller?.setVolume(0.0); + } else { + controller?.setVolume(lastVolume.value); + } + }); + + // When the position changes, seek to the position + ref.listen(videoPlayerControlsProvider.select((value) => value.position), + (_, position) { + final video = controller?.videoPlayerController.value; + if (video == null) { + // No seeeking if there is no video + return; + } + + // Find the position to seek to + final Duration seek = video.duration * (position / 100.0); + controller?.seekTo(seek); + }); + + // When the custom video controls paus or plays + ref.listen(videoPlayerControlsProvider.select((value) => value.pause), + (_, pause) { + if (pause) { + controller?.pause(); + } else { + controller?.play(); + } + }); + + // Updates the [videoPlaybackValueProvider] with the current + // position and duration of the video from the Chewie [controller] + // Also sets the error if there is an error in the playback + void updateVideoPlayback() { + ref.read(videoPlaybackValueProvider.notifier).value = + VideoPlaybackValue.fromController( + controller?.videoPlayerController, + ); + } + + // Hide the controls when we load + useEffect( + () { + ref.read(showControlsProvider.notifier).show = false; + return null; + }, + [], + ); + + // Adds and removes the listener to the video player + useEffect( + () { + // Guard no controller + if (controller == null) { + return null; + } + + final video = controller.videoPlayerController.value; + + // Hold initial volume + lastVolume.value = video.volume; + + // Subscribes to listener + controller.videoPlayerController.addListener(updateVideoPlayback); + return () { + // Removes listener when we dispose + controller.videoPlayerController.removeListener(updateVideoPlayback); + }; + }, + [controller], + ); + final size = MediaQuery.of(context).size; - print('showControls $showControls and isMotion $isMotionVideo'); return PopScope( + onPopInvoked: (pop) { + ref.read(videoPlaybackValueProvider.notifier).value = + VideoPlaybackValue.uninitialized(); + }, child: AnimatedSwitcher( duration: const Duration(milliseconds: 400), child: Stack( @@ -81,19 +167,6 @@ class VideoViewerPage extends HookWidget { controller: controller, ), ), - if (controller != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: SizedBox( - width: size.width, - child: Visibility( - visible: true, - child: const VideoControls(), - ), - ), - ), ], ), ), diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index af57c272ae..dc4f7d76b8 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -69,6 +69,7 @@ class MemoryCard extends StatelessWidget { return Hero( tag: 'memory-${asset.id}', child: VideoViewerPage( + key: ValueKey(asset), asset: asset, showDownloadingIndicator: false, placeholder: ImmichImage( diff --git a/mobile/lib/shared/ui/hooks/timer_hook.dart b/mobile/lib/shared/ui/hooks/timer_hook.dart new file mode 100644 index 0000000000..a78fed42c3 --- /dev/null +++ b/mobile/lib/shared/ui/hooks/timer_hook.dart @@ -0,0 +1,48 @@ +import 'package:async/async.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +RestartableTimer useTimer( + Duration duration, + void Function() callback, +) { + return use( + _TimerHook( + duration: duration, + callback: callback, + ), + ); +} + +class _TimerHook extends Hook { + final Duration duration; + final void Function() callback; + + const _TimerHook({ + required this.duration, + required this.callback, + }); + @override + HookState> createState() => + _TimerHookState(); +} + +class _TimerHookState extends HookState { + late RestartableTimer timer; + @override + void initHook() { + super.initHook(); + timer = RestartableTimer(hook.duration, hook.callback); + } + + @override + RestartableTimer build(BuildContext context) { + return timer; + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f27351898d..f7a57bb2b3 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "2.4.2" async: - dependency: transitive + dependency: "direct main" description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 04056977a4..cf29809caa 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: timezone: ^0.9.2 octo_image: ^2.0.0 thumbhash: 0.1.0+1 + async: ^2.11.0 openapi: path: openapi