From fb2cfcb64023bfdc12eb68d42719525e87bd5681 Mon Sep 17 00:00:00 2001 From: Sergey Kondrikov Date: Mon, 26 Jun 2023 18:27:47 +0300 Subject: [PATCH] feat(mobile): custom video player controls (#2960) * Remove toggle fullscreen button * Implement custom video player controls * Move Padding into Container --- .../providers/show_controls.provider.dart | 21 ++ .../video_player_controls_provider.dart | 46 ++++ .../video_player_value_provider.dart | 35 +++ .../asset_viewer/ui/animated_play_pause.dart | 57 ++++ .../asset_viewer/ui/center_play_button.dart | 53 ++++ .../ui/video_player_controls.dart | 207 +++++++++++++++ .../asset_viewer/views/gallery_viewer.dart | 251 +++++++++++++----- .../asset_viewer/views/video_viewer_page.dart | 8 +- mobile/lib/utils/immich_app_theme.dart | 6 + mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 11 files changed, 618 insertions(+), 69 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/center_play_button.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/video_player_controls.dart diff --git a/mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart b/mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart new file mode 100644 index 0000000000..f2d11b4506 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart @@ -0,0 +1,21 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final showControlsProvider = StateNotifierProvider((ref) { + return ShowControls(ref); +}); + +class ShowControls extends StateNotifier { + ShowControls(this.ref) : super(true); + + final Ref ref; + + bool get show => state; + + set show(bool value) { + state = value; + } + + void toggle() { + state = !state; + } +} 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 new file mode 100644 index 0000000000..b73824f864 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart @@ -0,0 +1,46 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class VideoPlaybackControls { + VideoPlaybackControls({required this.position, required this.mute}); + + final double position; + final bool mute; +} + +final videoPlayerControlsProvider = + StateNotifierProvider((ref) { + return VideoPlayerControls(ref); +}); + +class VideoPlayerControls extends StateNotifier { + VideoPlayerControls(this.ref) + : super( + VideoPlaybackControls( + position: 0, + mute: false, + ), + ); + + final Ref ref; + + VideoPlaybackControls get value => state; + + set value(VideoPlaybackControls value) { + state = value; + } + + double get position => state.position; + bool get mute => state.mute; + + set position(double value) { + state = VideoPlaybackControls(position: value, mute: state.mute); + } + + set mute(bool value) { + state = VideoPlaybackControls(position: state.position, mute: value); + } + + void toggleMute() { + state = VideoPlaybackControls(position: state.position, mute: !state.mute); + } +} 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 new file mode 100644 index 0000000000..66f9389a09 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart @@ -0,0 +1,35 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class VideoPlaybackValue { + VideoPlaybackValue({required this.position, required this.duration}); + + final Duration position; + final Duration duration; +} + +final videoPlaybackValueProvider = + StateNotifierProvider((ref) { + return VideoPlaybackValueState(ref); +}); + +class VideoPlaybackValueState extends StateNotifier { + VideoPlaybackValueState(this.ref) + : super( + VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + ), + ); + + final Ref ref; + + VideoPlaybackValue get value => state; + + set value(VideoPlaybackValue value) { + state = value; + } + + set position(Duration value) { + state = VideoPlaybackValue(position: value, duration: state.duration); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart b/mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart new file mode 100644 index 0000000000..bee5636e18 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// A widget that animates implicitly between a play and a pause icon. +class AnimatedPlayPause extends StatefulWidget { + const AnimatedPlayPause({ + Key? key, + required this.playing, + this.size, + this.color, + }) : super(key: key); + + final double? size; + final bool playing; + final Color? color; + + @override + State createState() => AnimatedPlayPauseState(); +} + +class AnimatedPlayPauseState extends State + with SingleTickerProviderStateMixin { + late final animationController = AnimationController( + vsync: this, + value: widget.playing ? 1 : 0, + duration: const Duration(milliseconds: 300), + ); + + @override + void didUpdateWidget(AnimatedPlayPause oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.playing != oldWidget.playing) { + if (widget.playing) { + animationController.forward(); + } else { + animationController.reverse(); + } + } + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/center_play_button.dart b/mobile/lib/modules/asset_viewer/ui/center_play_button.dart new file mode 100644 index 0000000000..437daa8979 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/center_play_button.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/animated_play_pause.dart'; + +class CenterPlayButton extends StatelessWidget { + const CenterPlayButton({ + Key? key, + required this.backgroundColor, + this.iconColor, + required this.show, + required this.isPlaying, + required this.isFinished, + this.onPressed, + }) : super(key: key); + + final Color backgroundColor; + final Color? iconColor; + final bool show; + final bool isPlaying; + final bool isFinished; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.transparent, + child: Center( + child: UnconstrainedBox( + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + child: IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12.0), + icon: isFinished + ? Icon(Icons.replay, color: iconColor) + : AnimatedPlayPause( + color: iconColor, + playing: isPlaying, + ), + onPressed: onPressed, + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart new file mode 100644 index 0000000000..25ec8ab96a --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart @@ -0,0 +1,207 @@ +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/immich_loading_indicator.dart'; +import 'package:video_player/video_player.dart'; + +class VideoPlayerControls extends ConsumerStatefulWidget { + const VideoPlayerControls({ + Key? key, + }) : super(key: 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: ImmichLoadingIndicator(), + ) + 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; + + if (oldController != chewieController) { + _dispose(); + _initialize(); + } + + super.didChangeDependencies(); + } + + Widget _buildHitArea() { + final bool isFinished = _latestValue.position >= _latestValue.duration; + + return GestureDetector( + onTap: () { + if (_latestValue.isPlaying) { + ref.read(showControlsProvider.notifier).show = false; + } else { + _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 { + _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); + + controller.addListener(_updateState); + _latestValue = controller.value; + + 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.isNegative + ? ChewieController.defaultHideControlsTimer + : chewieController.hideControlsTimer; + _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 38a79abd7e..ba0f44ad09 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -6,8 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/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/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/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -49,9 +52,9 @@ class GalleryViewerPage extends HookConsumerWidget { final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isZoomed = useState(false); - final showAppBar = useState(true); final isPlayingMotionVideo = useState(false); final isPlayingVideo = useState(false); + final progressValue = useState(0.0); Offset? localPosition; final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; final currentIndex = useState(initialIndex); @@ -60,15 +63,6 @@ class GalleryViewerPage extends HookConsumerWidget { Asset asset() => watchedAsset.value ?? currentAsset; - showAppBar.addListener(() { - // Change to and from immersive mode, hiding navigation and app bar - if (showAppBar.value) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - }); - useEffect( () { isLoadPreview.value = @@ -277,15 +271,11 @@ class GalleryViewerPage extends HookConsumerWidget { } buildAppBar() { - final show = (showAppBar.value || // onTap has the final say - (showAppBar.value && !isZoomed.value)) && - !isPlayingVideo.value; - return IgnorePointer( - ignoring: !show, + ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: show ? 1.0 : 0.0, + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Container( color: Colors.black.withOpacity(0.4), child: TopControlAppBar( @@ -313,65 +303,157 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - buildBottomBar() { - final show = (showAppBar.value || // onTap has the final say - (showAppBar.value && !isZoomed.value)) && - !isPlayingVideo.value; + Widget buildProgressBar() { + final playerValue = ref.watch(videoPlaybackValueProvider); + return Expanded( + child: Slider( + value: playerValue.duration == Duration.zero + ? 0.0 + : playerValue.position.inMicroseconds / + playerValue.duration.inMicroseconds * + 100, + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: Colors.white.withOpacity(0.75), + onChanged: (position) { + progressValue.value = position; + ref.read(videoPlayerControlsProvider.notifier).position = position; + }, + ), + ); + } + + Text buildPosition() { + final position = ref + .watch(videoPlaybackValueProvider.select((value) => value.position)); + + return Text( + _formatDuration(position), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ); + } + + Text buildDuration() { + final duration = ref + .watch(videoPlaybackValueProvider.select((value) => value.duration)); + + return Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ); + } + + Widget buildMuteButton() { + return 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, + ); + } + + buildBottomBar() { return IgnorePointer( - ignoring: !show, + ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: show ? 1.0 : 0.0, - child: BottomNavigationBar( - backgroundColor: Colors.black.withOpacity(0.4), - unselectedIconTheme: const IconThemeData(color: Colors.white), - selectedIconTheme: const IconThemeData(color: Colors.white), - unselectedLabelStyle: const TextStyle(color: Colors.black), - selectedLabelStyle: const TextStyle(color: Colors.black), - showSelectedLabels: false, - showUnselectedLabels: false, - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.ios_share_rounded), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - asset().isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + child: Column( + children: [ + Visibility( + visible: !asset().isImage && !isPlayingMotionVideo.value, + 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: [ + buildPosition(), + buildProgressBar(), + buildDuration(), + buildMuteButton(), + ], ), - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + ), + ), + BottomNavigationBar( + backgroundColor: Colors.black.withOpacity(0.4), + unselectedIconTheme: const IconThemeData(color: Colors.white), + selectedIconTheme: const IconThemeData(color: Colors.white), + unselectedLabelStyle: const TextStyle(color: Colors.black), + selectedLabelStyle: const TextStyle(color: Colors.black), + showSelectedLabels: false, + showUnselectedLabels: false, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.ios_share_rounded), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ), + asset().isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + ], + onTap: (index) { + switch (index) { + case 0: + shareAsset(); + break; + case 1: + handleArchive(asset()); + break; + case 2: + handleDelete(asset()); + break; + } + }, ), ], - onTap: (index) { - switch (index) { - case 0: - shareAsset(); - break; - case 1: - handleArchive(asset()); - break; - case 2: - handleDelete(asset()); - break; - } - }, ), ), ); } + ref.listen(showControlsProvider, (_, show) { + if (show) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + }); + ImageProvider imageProvider(Asset asset) { if (asset.isLocal) { return localImageProvider(asset); @@ -405,7 +487,6 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGallery.builder( scaleStateChangedCallback: (state) { isZoomed.value = state != PhotoViewScaleState.initial; - showAppBar.value = !isZoomed.value; }, pageController: controller, scrollPhysics: isZoomed.value @@ -426,6 +507,8 @@ class GalleryViewerPage extends HookConsumerWidget { precacheNextImage(value - 1); } currentIndex.value = value; + progressValue.value = 0.0; + HapticFeedback.selectionClick(); }, loadingBuilder: isLoadPreview.value @@ -493,8 +576,9 @@ class GalleryViewerPage extends HookConsumerWidget { localPosition = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), - onTapDown: (_, __, ___) => - showAppBar.value = !showAppBar.value, + onTapDown: (_, __, ___) { + ref.read(showControlsProvider.notifier).toggle(); + }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( tag: asset.id, @@ -519,7 +603,7 @@ class GalleryViewerPage extends HookConsumerWidget { filterQuality: FilterQuality.high, maxScale: 1.0, minScale: 1.0, - basePosition: Alignment.bottomCenter, + basePosition: Alignment.center, child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => isPlayingVideo.value = false, @@ -559,4 +643,37 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } + + String _formatDuration(Duration position) { + final ms = position.inMilliseconds; + + int seconds = ms ~/ 1000; + final int hours = seconds ~/ 3600; + seconds = seconds % 3600; + final minutes = seconds ~/ 60; + seconds = seconds % 60; + + final hoursString = hours >= 10 + ? '$hours' + : hours == 0 + ? '00' + : '0$hours'; + + final minutesString = minutes >= 10 + ? '$minutes' + : minutes == 0 + ? '00' + : '0$minutes'; + + final secondsString = seconds >= 10 + ? '$seconds' + : seconds == 0 + ? '00' + : '0$seconds'; + + final formattedTime = + '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; + + return formattedTime; + } } 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 fb23fd3ef6..c6b836121e 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -5,11 +5,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:chewie/chewie.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: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/wakelock.dart'; // ignore: must_be_immutable class VideoViewerPage extends HookConsumerWidget { @@ -130,13 +132,16 @@ class _VideoPlayerState extends State { videoPlayerController.addListener(() { if (videoPlayerController.value.isInitialized) { if (videoPlayerController.value.isPlaying) { + Wakelock.enable(); widget.onPlaying?.call(); } else if (!videoPlayerController.value.isPlaying) { + Wakelock.disable(); widget.onPaused?.call(); } if (videoPlayerController.value.position == videoPlayerController.value.duration) { + Wakelock.disable(); widget.onVideoEnded(); } } @@ -170,9 +175,10 @@ class _VideoPlayerState extends State { videoPlayerController: videoPlayerController, autoPlay: true, autoInitialize: true, - allowFullScreen: true, + allowFullScreen: false, allowedScreenSleep: false, showControls: !widget.isMotionVideo, + customControls: const VideoPlayerControls(), hideControlsTimer: const Duration(seconds: 5), ); } diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index aecf102d89..5eb3551738 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -24,6 +24,10 @@ ThemeData base = ThemeData( chipTheme: const ChipThemeData( side: BorderSide.none, ), + sliderTheme: const SliderThemeData( + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + trackHeight: 2.0, + ), ); ThemeData immichLightTheme = ThemeData( @@ -99,6 +103,7 @@ ThemeData immichLightTheme = ThemeData( ), ), chipTheme: base.chipTheme, + sliderTheme: base.sliderTheme, popupMenuTheme: const PopupMenuThemeData( shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(10)), @@ -218,6 +223,7 @@ ThemeData immichDarkTheme = ThemeData( ), ), chipTheme: base.chipTheme, + sliderTheme: base.sliderTheme, popupMenuTheme: const PopupMenuThemeData( shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(10)), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7363a24444..bd5ccb14dd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1409,7 +1409,7 @@ packages: source: hosted version: "11.3.0" wakelock: - dependency: transitive + dependency: "direct main" description: name: wakelock sha256: "769ecf42eb2d07128407b50cb93d7c10bd2ee48f0276ef0119db1d25cc2f87db" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7c3941bb4b..42b95c9665 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: permission_handler: ^10.2.0 device_info_plus: ^8.1.0 crypto: ^3.0.3 # TODO remove once native crypto is used on iOS + wakelock: ^0.6.2 openapi: path: openapi