Преглед на файлове

feat(mobile): custom video player controls (#2960)

* Remove toggle fullscreen button

* Implement custom video player controls

* Move Padding into Container
Sergey Kondrikov преди 2 години
родител
ревизия
fb2cfcb640

+ 21 - 0
mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart

@@ -0,0 +1,21 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+final showControlsProvider = StateNotifierProvider<ShowControls, bool>((ref) {
+  return ShowControls(ref);
+});
+
+class ShowControls extends StateNotifier<bool> {
+  ShowControls(this.ref) : super(true);
+
+  final Ref ref;
+
+  bool get show => state;
+
+  set show(bool value) {
+    state = value;
+  }
+
+  void toggle() {
+    state = !state;
+  }
+}

+ 46 - 0
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<VideoPlayerControls, VideoPlaybackControls>((ref) {
+  return VideoPlayerControls(ref);
+});
+
+class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
+  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);
+  }
+}

+ 35 - 0
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<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
+  return VideoPlaybackValueState(ref);
+});
+
+class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
+  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);
+  }
+}

+ 57 - 0
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<StatefulWidget> createState() => AnimatedPlayPauseState();
+}
+
+class AnimatedPlayPauseState extends State<AnimatedPlayPause>
+    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,
+      ),
+    );
+  }
+}

+ 53 - 0
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,
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 207 - 0
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<VideoPlayerControls>
+    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<void> _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);
+    }
+  }
+}

+ 184 - 67
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<bool>(false);
-    final showAppBar = useState<bool>(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;
+  }
 }

+ 7 - 1
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<VideoPlayer> {
     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<VideoPlayer> {
       videoPlayerController: videoPlayerController,
       autoPlay: true,
       autoInitialize: true,
-      allowFullScreen: true,
+      allowFullScreen: false,
       allowedScreenSleep: false,
       showControls: !widget.isMotionVideo,
+      customControls: const VideoPlayerControls(),
       hideControlsTimer: const Duration(seconds: 5),
     );
   }

+ 6 - 0
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)),

+ 1 - 1
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"

+ 1 - 0
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