From 4ef4cc8016b9751c3aee32e9672d3d4acfec25a3 Mon Sep 17 00:00:00 2001
From: martyfuhry <martyfuhry@gmail.com>
Date: Tue, 5 Mar 2024 22:42:22 -0500
Subject: [PATCH] refactor(mobile): Refactor video player page and gallery
 bottom app bar (#7625)

* Fixes double video auto initialize issue and placeholder for video controller

* WIP unravel stack index

* Refactors video player controller

format

fixing video

format

Working

format

* Fixes hide on pause

* Got hiding when tapped working

* Hides controls when video starts and fixes placeholder for memory card

Remove prints

* Fixes show controls with microtask

* fix LivePhotos not playing

* removes unused function callbacks and moves wakelock

* Update motion video

* Fixing motion photo playing

* Renames to isPlayingVideo

* Fixes playing video on change

* pause on dispose

* fixing issues with sync between controls

* Adds gallery app bar

* Switches to memoized

* Fixes pause

* Revert "Switches to memoized"

This reverts commit 234e6741dea05aa0b967dde746f1d625f15bed94.

* uses stateful widget

* Fixes double video play by using provider and new chewie video player

wip

format

Fixes motion photos

format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
---
 .../hooks/chewiew_controller_hook.dart        |  97 +--
 .../providers/asset_stack.provider.dart       |   8 +
 .../providers/asset_stack.provider.g.dart     | Bin 0 -> 4361 bytes
 .../video_player_controller_provider.dart     |  44 ++
 .../video_player_controller_provider.g.dart   | Bin 0 -> 4820 bytes
 .../video_player_controls_provider.dart       |  58 +-
 .../video_player_value_provider.dart          |  71 +-
 .../asset_viewer/ui/bottom_gallery_bar.dart   | 345 +++++++++
 .../ui/custom_video_player_controls.dart      | 107 +++
 .../asset_viewer/ui/gallery_app_bar.dart      | 110 +++
 .../asset_viewer/ui/video_controls.dart       | 125 ++++
 .../modules/asset_viewer/ui/video_player.dart |  45 ++
 .../ui/video_player_controls.dart             | 209 ------
 .../asset_viewer/views/gallery_viewer.dart    | 675 +++---------------
 .../asset_viewer/views/video_viewer_page.dart | 158 +++-
 .../map/providers/map_state.provider.g.dart   | Bin 933 -> 933 bytes
 .../lib/modules/memories/ui/memory_card.dart  |  10 +-
 mobile/lib/routing/router.gr.dart             |  24 +-
 mobile/lib/shared/ui/hooks/timer_hook.dart    |  48 ++
 mobile/pubspec.lock                           |   2 +-
 mobile/pubspec.yaml                           |   1 +
 21 files changed, 1205 insertions(+), 932 deletions(-)
 create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart
 create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart
 create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart
 create mode 100644 mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart
 create mode 100644 mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart
 create mode 100644 mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
 create mode 100644 mobile/lib/modules/asset_viewer/ui/video_controls.dart
 create mode 100644 mobile/lib/modules/asset_viewer/ui/video_player.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 224eb838e7..5daeb389ec 100644
--- a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart
+++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart
@@ -1,26 +1,19 @@
-import 'dart:async';
-
 import 'package:chewie/chewie.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/models/store.dart';
 import 'package:video_player/video_player.dart';
-import 'package:immich_mobile/shared/models/store.dart' as store;
-import 'package:wakelock_plus/wakelock_plus.dart';
 
 /// Provides the initialized video player controller
 /// If the asset is local, use the local file
 /// Otherwise, use a video player with a URL
-ChewieController? useChewieController(
-  Asset asset, {
+ChewieController useChewieController({
+  required VideoPlayerController controller,
   EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
     bottom: 100,
   ),
   bool showOptions = true,
   bool showControlsOnInitialize = false,
   bool autoPlay = true,
-  bool autoInitialize = true,
   bool allowFullScreen = false,
   bool allowedScreenSleep = false,
   bool showControls = true,
@@ -33,7 +26,7 @@ ChewieController? useChewieController(
 }) {
   return use(
     _ChewieControllerHook(
-      asset: asset,
+      controller: controller,
       placeholder: placeholder,
       showOptions: showOptions,
       controlsSafeAreaMinimum: controlsSafeAreaMinimum,
@@ -43,7 +36,6 @@ ChewieController? useChewieController(
       hideControlsTimer: hideControlsTimer,
       showControlsOnInitialize: showControlsOnInitialize,
       showControls: showControls,
-      autoInitialize: autoInitialize,
       allowedScreenSleep: allowedScreenSleep,
       onPlaying: onPlaying,
       onPaused: onPaused,
@@ -52,13 +44,12 @@ ChewieController? useChewieController(
   );
 }
 
-class _ChewieControllerHook extends Hook<ChewieController?> {
-  final Asset asset;
+class _ChewieControllerHook extends Hook<ChewieController> {
+  final VideoPlayerController controller;
   final EdgeInsets controlsSafeAreaMinimum;
   final bool showOptions;
   final bool showControlsOnInitialize;
   final bool autoPlay;
-  final bool autoInitialize;
   final bool allowFullScreen;
   final bool allowedScreenSleep;
   final bool showControls;
@@ -70,14 +61,13 @@ class _ChewieControllerHook extends Hook<ChewieController?> {
   final VoidCallback? onVideoEnded;
 
   const _ChewieControllerHook({
-    required this.asset,
+    required this.controller,
     this.controlsSafeAreaMinimum = const EdgeInsets.only(
       bottom: 100,
     ),
     this.showOptions = true,
     this.showControlsOnInitialize = false,
     this.autoPlay = true,
-    this.autoInitialize = true,
     this.allowFullScreen = false,
     this.allowedScreenSleep = false,
     this.showControls = true,
@@ -94,28 +84,33 @@ class _ChewieControllerHook extends Hook<ChewieController?> {
 }
 
 class _ChewieControllerHookState
-    extends HookState<ChewieController?, _ChewieControllerHook> {
-  ChewieController? chewieController;
-  VideoPlayerController? videoPlayerController;
-
-  @override
-  void initHook() async {
-    super.initHook();
-    unawaited(_initialize());
-  }
+    extends HookState<ChewieController, _ChewieControllerHook> {
+  late ChewieController chewieController = ChewieController(
+    videoPlayerController: hook.controller,
+    controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
+    showOptions: hook.showOptions,
+    showControlsOnInitialize: hook.showControlsOnInitialize,
+    autoPlay: hook.autoPlay,
+    allowFullScreen: hook.allowFullScreen,
+    allowedScreenSleep: hook.allowedScreenSleep,
+    showControls: hook.showControls,
+    customControls: hook.customControls,
+    placeholder: hook.placeholder,
+    hideControlsTimer: hook.hideControlsTimer,
+  );
 
   @override
   void dispose() {
-    chewieController?.dispose();
-    videoPlayerController?.dispose();
+    chewieController.dispose();
     super.dispose();
   }
 
   @override
-  ChewieController? build(BuildContext context) {
+  ChewieController build(BuildContext context) {
     return chewieController;
   }
 
+  /*
   /// Initializes the chewie controller and video player controller
   Future<void> _initialize() async {
     if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
@@ -141,39 +136,21 @@ class _ChewieControllerHookState
       );
     }
 
-    videoPlayerController!.addListener(() {
-      final value = videoPlayerController!.value;
-      if (value.isPlaying) {
-        WakelockPlus.enable();
-        hook.onPlaying?.call();
-      } else if (!value.isPlaying) {
-        WakelockPlus.disable();
-        hook.onPaused?.call();
-      }
-
-      if (value.position == value.duration) {
-        WakelockPlus.disable();
-        hook.onVideoEnded?.call();
-      }
-    });
-
     await videoPlayerController!.initialize();
 
-    setState(() {
-      chewieController = ChewieController(
-        videoPlayerController: videoPlayerController!,
-        controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
-        showOptions: hook.showOptions,
-        showControlsOnInitialize: hook.showControlsOnInitialize,
-        autoPlay: hook.autoPlay,
-        autoInitialize: hook.autoInitialize,
-        allowFullScreen: hook.allowFullScreen,
-        allowedScreenSleep: hook.allowedScreenSleep,
-        showControls: hook.showControls,
-        customControls: hook.customControls,
-        placeholder: hook.placeholder,
-        hideControlsTimer: hook.hideControlsTimer,
-      );
-    });
+    chewieController = ChewieController(
+      videoPlayerController: videoPlayerController!,
+      controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
+      showOptions: hook.showOptions,
+      showControlsOnInitialize: hook.showControlsOnInitialize,
+      autoPlay: hook.autoPlay,
+      allowFullScreen: hook.allowFullScreen,
+      allowedScreenSleep: hook.allowedScreenSleep,
+      showControls: hook.showControls,
+      customControls: hook.customControls,
+      placeholder: hook.placeholder,
+      hideControlsTimer: hook.hideControlsTimer,
+    );
   }
+  */
 }
diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart
index 5c20e1479f..b6928c6ba8 100644
--- a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart
+++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart
@@ -2,6 +2,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:isar/isar.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+part 'asset_stack.provider.g.dart';
 
 class AssetStackNotifier extends StateNotifier<List<Asset>> {
   final Asset _asset;
@@ -49,3 +52,8 @@ final assetStackProvider =
       .sortByFileCreatedAtDesc()
       .findAll();
 });
+
+@riverpod
+int assetStackIndex(AssetStackIndexRef ref, Asset asset) {
+  return -1;
+}
diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart
new file mode 100644
index 0000000000000000000000000000000000000000..142e46d32292f8f30a135d930c2ca43c263dba4e
GIT binary patch
literal 4361
zcmb_fZBOGy5dNNDF&`>$T519mIC79XN_bOMr~s)tRjtplC$O#KP1oxbRPo<Cv+H%7
zjRSWo`2^mX*?D=Mnc2-vI6u8Qy*a)+9m2`=@D#p>;Wb=c-@(t<!^^Wz@Z%@AIKCQo
zI@4HbkYiYnivo32=s5W+oGLjJDXK7~)AjeA&gLe3`({73b|Yq}rZPRp9968PdgCiP
zw_1t(9-?ndT(=z7WuD^WMO-`tT{s$mTJMdw)A8;u9&8`%@9ih=lD&g>qx6TJ?a?mo
z;m+<>+Uxb=tv#E8O*oNLfhmlYoWPLtc02s3lVo%QM7K|c#tB=8e>>3pKa7Gdr4WE}
zt3?7L*O17`NaPss-w&j-VZP4dti-O-2cRU0`&=sQ1Jx*=phgwZRrGO!xh}{c7+pAm
z-s9HT{$LFPHVEH=wf5e~{cmGI(0in0?DH*j2Vc>J!$a8WbtyP@MWHTLZaw;=a$+!m
zJ#+l*#XlCgpgOql-z;Y1=1!f8+RYv3<<}&_<gpRT*4AREty!-U)ng|X618hxc<xXx
zHpz`!M2NFO!fzfze-Aq!f2jy~CRN}acRPv93k}|9r$J~qW+u-Oe-=+f_GB>l+PxOb
zt@?nE8uPROYwnOrH!w(j%_b1o+22;H6*FG9;c3^=CUKSp27gAvW@UpuI7n5nT%m7O
zVXj7s3}=JakCJ+nqCQ9;R_Mb$YUj&!C2egcauzEQ6TerfPOBQXcsq{@z=?%1mz9N^
zK2|-&rudRtL_Esyuy#q=Y7IU>l;V`?hj}8fAduu`mYJ^9jPfM|6e%p)7%UoX_P8(b
zhYEM6;=B-=M#1n6oO{9Gz+{cO@=BSdRM`3PfeQ>dbzeI%ObzDLa(Si!@dd1IWtTHi
z^Xs_Oawv+aEYO{O4cS(pZ3RdApdUmLM#N@Rd0bJ6AqEgA9GgMu1?@OvrAUa_<AeML
z#}=tpXn|n%Q@44}6ScppqcsJ&5`CK7o=YxK>rL4dN9FxF>Jx>r##>pc1TScA^(!zq
z*DO{?Qf49hahlJBl4iyR>%9Mjvk5OmX_@HtuBXd3J{T^w9Q50_k3}Enr0n}iwA>9B
znA=od31&Y+4-?MrO8tdPxa(B4nSZAyNdbFyT@`#d73Gvx$tW>(xRP3o1*(l|%~AMw
zDHLs0R*DO3ukj9=t*2Gsm1vgCwS%M8G{eF>Ftjl!<b&rf^s4}BCfO9xq7!E}Q5N4z
zjmC`)aMR{jp&u;Acs)8R^F#}o2VSy?VfAR}g3T?v;>|r*6L*UvuT<h*<d*(6G^ia|
z0-`hG7nadqcM0nP|J&XV8icoWpJ$crSZ4E7a_+#wxak{~&Cgbr+ZSBf27H4pK(BmW
zu@Wpd>eCE)H@E%TCZzgAPY!+-)N69&mD8ozi8H?1ujNJsk{HFWCOA3@u19}jLT@<z
zfXchYd;_u6p51M*wr2J0azWM{NLyAQMk7x{UW-p;id8_g=D24_;}I0P7fx>jRGDir
z!Mmp^hECbUR=;1`6j^e^y3^EJNaFm_6;>|_)~fPG6L}&YMeesLW{#w<%jKh!#Zi0r
zfb+)}d^UXl%&%oGPffp&d@|)U=`liPAE;i2FIW1dx!;77iTfXF4Q=5tv|dA$?XS~n
z>7*QyPILG3BH(=|uw9~s&kjIc3mgu&epy9hsiLvSXi#e^`B6r|X+15<5er0e92FR6
rm_{aJ{1&FSt4O1=Fo&+CMiZQjcyf5>Gf@cIdd<BW3r+9-O0@GIWkhZK

literal 0
HcmV?d00001

diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart
new file mode 100644
index 0000000000..714c38e2ab
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart
@@ -0,0 +1,44 @@
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:video_player/video_player.dart';
+
+part 'video_player_controller_provider.g.dart';
+
+@riverpod
+Future<VideoPlayerController> videoPlayerController(
+  VideoPlayerControllerRef ref, {
+  required Asset asset,
+}) async {
+  late VideoPlayerController controller;
+  if (asset.isLocal && asset.livePhotoVideoId == null) {
+    // Use a local file for the video player controller
+    final file = await asset.local!.file;
+    if (file == null) {
+      throw Exception('No file found for the video');
+    }
+    controller = VideoPlayerController.file(file);
+  } else {
+    // Use a network URL for the video player controller
+    final serverEndpoint = Store.get(StoreKey.serverEndpoint);
+    final String videoUrl = asset.livePhotoVideoId != null
+        ? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}'
+        : '$serverEndpoint/asset/file/${asset.remoteId}';
+
+    final url = Uri.parse(videoUrl);
+    final accessToken = Store.get(StoreKey.accessToken);
+
+    controller = VideoPlayerController.networkUrl(
+      url,
+      httpHeaders: {"x-immich-user-token": accessToken},
+    );
+  }
+
+  await controller.initialize();
+
+  ref.onDispose(() {
+    controller.dispose();
+  });
+
+  return controller;
+}
diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart
new file mode 100644
index 0000000000000000000000000000000000000000..a9b287e953e17749855607cbf48fce353ac5a151
GIT binary patch
literal 4820
zcmb_gZByGu5dQ98u^&1?ZmBUOK<WSq#gJq=#hJjQlj*dIEUn=>vd%r7T$oP&dw1`2
zvLsH%nc`32+uPf>=h<EF?7-Xe%kz(?*XLt6n~cxlM;K4wa&is7PR8#qKEuzS;oa%w
zxYt`mN`sui)>0%W!$lh1qY7i0X(iK?{#+=@M{1Ce<kqWRZ)XR-f3gp2`zV&E7BYE@
z8LCK2^~6u~uCx-_4TRqrA3qou&nge!Mfq*ehvSnT&_`_@?oD6pCwOqM|6*^rw|lsI
z_;Pr7u)mLc`xyVUmmI!KUJems8;cz{lM8_f%#@tNn4@_${-qbEQJzD1b)RdT^9%4V
zsjvM%2!lQa6Cl@-7BPrSLoDZ0kzv4p-_o6J^X)Q93+$Ww02D59lSzdmpv*;c)RZE6
zioTp<rgP&1--Tls-tErp2cAJ-zWf2KwpT{(e=7@u;oWdJJmgpC4Su2zM@O(b?2~gQ
zoTv+xnG^W4G~(n04$Se_mj5%63Ce|Y|H*tdW<IY%QJMMNS@|u2FmbH8vb)=K)f@FP
zP(wDN5vYA@!q*<fVu#GQLWC&ICH(HK)jtNkHy=tsUPu*qh59{$Z?3_6=x$Sed}746
z(QpyXMS5>)b2EEvrLWOt>HzOFW=RfK{n2TDpT(cJu#dbqsYDnrn^^z^9QN0%spik-
z`n;N|$i-2b25cU&1^!(KMd&}xbJRdUM7>QXUtR4gTmNen21XUw+oP2dfE7yKNa9FE
z2_xCH%HRgIvvgA4u@A&@87UDFx2#vNDC>*;Xt`lLZ(8f52bJ%2vrBTb`FT%V6-`q-
zs_auXJ14IpOmIO3f>|sur`pVlG&S^CcYK`!avPdDPMS*VWV?Yql;dj^Wx3GQoyJdK
z+!}?p^H6)}lz9*e+tOZhwjrZ7K<$fK*V2b*7j5cS8<Wf$P&;*|3>>Ua3oXYYU&tIU
z3SxY8QXTOACUmbY*T;c40(~p;K}c-{RTiZVZj9eR;f!0qpDXK<jP)5|(+{4HIAgf1
zJ_Zd9EZehwU5rPDk^gBIzq&Z*dGu(O>o*dd7B_EEpDBzqUdckm_>P+Ns9~$)&#9E@
zfNeU@mO@E0Py|~%_6?Tv!9vNTi1k*#b(j5mZSd+j7_A$+nwGBMKk~=ea>by*6m=Dy
z;Awm7ttY3wQ%mD1=J^NNCAOvzc(uE9wyQ8N7BpFhvB}S+)M6%3ZI`oN)m*F;r{5aU
zI;hj0SKtw!9;ovdjaN07OY6X(t?CBU2}fWZr~qgKMbloC)*I&ol5_nFT=V-)=vxa)
z%Q6$y#V;2{7Hc81z?%+&D^NWzd9PX3v&{l~(`tJzd(Ixm3sQ+2ky(DRL4eBCjv{ud
z+J(R<s>sM1!vC%#oEnu_B3b2vwOO{{KI%+^MuQv~%&yKli=-{sNh)oH9*~qa`{$a@
zCUTx4FE?Gew&5y;Xp?iJnb|x-ZvnV8xJz8KexsNaK@v)NF@@vfU^4v+V_KQ@E!9r*
zE%P0SjvnFOglEs}Z96iNPCN4P!eln0`pCn(5chcAI?iN*<%2ZI*`4aRu~WJQa<~nu
z$h4T__5A_|?y~U^)?H-DZEH^bmSOgCl&$Zu<-*3d%IJ{;b8#m!KMfM@j4GKyXR=AA
zE75KxxW2{J_6uJO*L>k0F*uDCum|+isTs>TfJVqnK;>$_NnIFYADwa5m#5M34-r>q
zNK8&Qt5Zv)3rAfK7_;RsPd0&NDGGq!09TfMTaFx}Y8?(|QiU^-(%7M?kd9L-NAGD~
zOj#h5voOaf#UwOQ<{vQV2M3XaMQ#pV3kc^ppK_;boi9Z$XhSgXYRonLY*UK%{sUgt
BBo_bx

literal 0
HcmV?d00001

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..d935358936 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<VideoPlaybackControls> {
       : super(
           VideoPlaybackControls(
             position: 0,
+            pause: false,
             mute: false,
           ),
         );
@@ -29,18 +35,62 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
     state = value;
   }
 
+  void reset() {
+    state = VideoPlaybackControls(
+      position: 0,
+      pause: false,
+      mute: false,
+    );
+  }
+
   double get position => state.position;
   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<VideoPlaybackValue> {
   VideoPlaybackValueState(this.ref)
       : super(
-          VideoPlaybackValue(
-            position: Duration.zero,
-            duration: Duration.zero,
-          ),
+          VideoPlaybackValue.uninitialized(),
         );
 
   final Ref ref;
@@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
   }
 
   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
new file mode 100644
index 0000000000..a7d5e4e71c
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart
@@ -0,0 +1,345 @@
+import 'dart:io';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+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/asset_viewer/providers/asset_stack.provider.dart';
+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/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';
+
+class BottomGalleryBar extends ConsumerWidget {
+  final Asset asset;
+  final bool showStack;
+  final int stackIndex;
+  final int totalAssets;
+  final bool showVideoPlayerControls;
+  final PageController controller;
+
+  const BottomGalleryBar({
+    super.key,
+    required this.showStack,
+    required this.stackIndex,
+    required this.asset,
+    required this.controller,
+    required this.totalAssets,
+    required this.showVideoPlayerControls,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
+
+    final stack = showStack && asset.stackChildrenCount > 0
+        ? ref.watch(assetStackStateProvider(asset))
+        : <Asset>[];
+    final stackElements = showStack ? [asset, ...stack] : <Asset>[];
+    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(
+        icon: Icon(
+          Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
+        ),
+        label: 'control_bottom_app_bar_share'.tr(),
+        tooltip: 'control_bottom_app_bar_share'.tr(),
+      ),
+      if (isOwner)
+        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(),
+              ),
+      if (isOwner && stack.isNotEmpty)
+        BottomNavigationBarItem(
+          icon: const Icon(Icons.burst_mode_outlined),
+          label: 'control_bottom_app_bar_stack'.tr(),
+          tooltip: 'control_bottom_app_bar_stack'.tr(),
+        ),
+      if (isOwner)
+        BottomNavigationBarItem(
+          icon: const Icon(Icons.delete_outline),
+          label: 'control_bottom_app_bar_delete'.tr(),
+          tooltip: 'control_bottom_app_bar_delete'.tr(),
+        ),
+      if (!isOwner)
+        BottomNavigationBarItem(
+          icon: const Icon(Icons.download_outlined),
+          label: 'download'.tr(),
+          tooltip: 'download'.tr(),
+        ),
+    ];
+
+    void removeAssetFromStack() {
+      if (stackIndex > 0 && showStack) {
+        ref
+            .read(assetStackStateProvider(asset).notifier)
+            .removeChild(stackIndex - 1);
+      }
+    }
+
+    void handleDelete() async {
+      // Cannot delete readOnly / external assets. They are handled through library offline jobs
+      if (asset.isReadOnly) {
+        ImmichToast.show(
+          durationInSecond: 1,
+          context: context,
+          msg: 'asset_action_delete_err_read_only'.tr(),
+          gravity: ToastGravity.BOTTOM,
+        );
+        return;
+      }
+      Future<bool> onDelete(bool force) async {
+        final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
+          {asset},
+          force: force,
+        );
+        if (isDeleted && isParent) {
+          if (totalAssets == 1) {
+            // Handle only one asset
+            context.popRoute();
+          } else {
+            // Go to next page otherwise
+            controller.nextPage(
+              duration: const Duration(milliseconds: 100),
+              curve: Curves.fastLinearToSlowEaseIn,
+            );
+          }
+        }
+        return isDeleted;
+      }
+
+      // Asset is trashed
+      if (isTrashEnabled && !isFromTrash) {
+        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 && asset.isRemote && isParent) {
+            ImmichToast.show(
+              durationInSecond: 1,
+              context: context,
+              msg: 'Asset trashed',
+              gravity: ToastGravity.BOTTOM,
+            );
+          }
+          removeAssetFromStack();
+        }
+        return;
+      }
+
+      // Asset is permanently removed
+      showDialog(
+        context: context,
+        builder: (BuildContext _) {
+          return DeleteDialog(
+            onDelete: () async {
+              final isDeleted = await onDelete(true);
+              if (isDeleted) {
+                removeAssetFromStack();
+              }
+            },
+          );
+        },
+      );
+    }
+
+    void showStackActionItems() {
+      showModalBottomSheet<void>(
+        context: context,
+        enableDrag: false,
+        builder: (BuildContext ctx) {
+          return SafeArea(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24.0),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  if (!isParent)
+                    ListTile(
+                      leading: const Icon(
+                        Icons.bookmark_border_outlined,
+                        size: 24,
+                      ),
+                      onTap: () async {
+                        await ref
+                            .read(assetStackServiceProvider)
+                            .updateStackParent(
+                              asset,
+                              stackElements.elementAt(stackIndex),
+                            );
+                        ctx.pop();
+                        context.popRoute();
+                      },
+                      title: const Text(
+                        "viewer_stack_use_as_main_asset",
+                        style: TextStyle(fontWeight: FontWeight.bold),
+                      ).tr(),
+                    ),
+                  ListTile(
+                    leading: const Icon(
+                      Icons.copy_all_outlined,
+                      size: 24,
+                    ),
+                    onTap: () async {
+                      if (isParent) {
+                        await ref
+                            .read(assetStackServiceProvider)
+                            .updateStackParent(
+                              asset,
+                              stackElements
+                                  .elementAt(1), // Next asset as parent
+                            );
+                        // Remove itself from stack
+                        await ref.read(assetStackServiceProvider).updateStack(
+                          stackElements.elementAt(1),
+                          childrenToRemove: [asset],
+                        );
+                        ctx.pop();
+                        context.popRoute();
+                      } else {
+                        await ref.read(assetStackServiceProvider).updateStack(
+                          asset,
+                          childrenToRemove: [
+                            stackElements.elementAt(stackIndex),
+                          ],
+                        );
+                        removeAssetFromStack();
+                        ctx.pop();
+                      }
+                    },
+                    title: const Text(
+                      "viewer_remove_from_stack",
+                      style: TextStyle(fontWeight: FontWeight.bold),
+                    ).tr(),
+                  ),
+                  ListTile(
+                    leading: const Icon(
+                      Icons.filter_none_outlined,
+                      size: 18,
+                    ),
+                    onTap: () async {
+                      await ref.read(assetStackServiceProvider).updateStack(
+                            asset,
+                            childrenToRemove: stack,
+                          );
+                      ctx.pop();
+                      context.popRoute();
+                    },
+                    title: const Text(
+                      "viewer_unstack",
+                      style: TextStyle(fontWeight: FontWeight.bold),
+                    ).tr(),
+                  ),
+                ],
+              ),
+            ),
+          );
+        },
+      );
+    }
+
+    shareAsset() {
+      if (asset.isOffline) {
+        ImmichToast.show(
+          durationInSecond: 1,
+          context: context,
+          msg: 'asset_action_share_err_offline'.tr(),
+          gravity: ToastGravity.BOTTOM,
+        );
+        return;
+      }
+      ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
+    }
+
+    handleArchive() {
+      ref.read(assetProvider.notifier).toggleArchive([asset]);
+      if (isParent) {
+        context.popRoute();
+        return;
+      }
+      removeAssetFromStack();
+    }
+
+    handleDownload() {
+      if (asset.isLocal) {
+        return;
+      }
+      if (asset.isOffline) {
+        ImmichToast.show(
+          durationInSecond: 1,
+          context: context,
+          msg: 'asset_action_share_err_offline'.tr(),
+          gravity: ToastGravity.BOTTOM,
+        );
+        return;
+      }
+
+      ref.read(imageViewerStateProvider.notifier).downloadAsset(
+            asset,
+            context,
+          );
+    }
+
+    List<Function(int)> actionslist = [
+      (_) => shareAsset(),
+      if (isOwner) (_) => handleArchive(),
+      if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
+      if (isOwner) (_) => handleDelete(),
+      if (!isOwner) (_) => handleDownload(),
+    ];
+
+    return IgnorePointer(
+      ignoring: !ref.watch(showControlsProvider),
+      child: AnimatedOpacity(
+        duration: const Duration(milliseconds: 100),
+        opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
+        child: Column(
+          children: [
+            Visibility(
+              visible: showVideoPlayerControls,
+              child: const VideoControls(),
+            ),
+            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: itemsList,
+              onTap: (index) {
+                if (index < actionslist.length) {
+                  actionslist[index].call(index);
+                }
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
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..0e8f14301a
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart
@@ -0,0 +1,107 @@
+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,
+      () {
+        final state = ref.read(videoPlaybackValueProvider).state;
+        // Do not hide on paused
+        if (state != VideoPlaybackState.paused) {
+          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;
+    });
+
+    /// Toggles between playing and pausing depending on the state of the video
+    void togglePlay() {
+      showControlsAndStartHideTimer();
+      final state = ref.read(videoPlaybackValueProvider).state;
+      if (state == VideoPlaybackState.playing) {
+        ref.read(videoPlayerControlsProvider.notifier).pause();
+      } else {
+        ref.read(videoPlayerControlsProvider.notifier).play();
+      }
+    }
+
+    return GestureDetector(
+      behavior: HitTestBehavior.opaque,
+      onTap: showControlsAndStartHideTimer,
+      child: AbsorbPointer(
+        absorbing: !ref.watch(showControlsProvider),
+        child: Stack(
+          children: [
+            if (showBuffering.value)
+              const Center(
+                child: DelayedLoadingIndicator(
+                  fadeInDuration: Duration(milliseconds: 400),
+                ),
+              )
+            else
+              GestureDetector(
+                onTap: () {
+                  if (state != VideoPlaybackState.playing) {
+                    togglePlay();
+                  }
+                  ref.read(showControlsProvider.notifier).show = false;
+                },
+                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/gallery_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
new file mode 100644
index 0000000000..a16f1f04d6
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
@@ -0,0 +1,110 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/providers/current_album.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/show_controls.provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/partner/providers/partner.provider.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/user.provider.dart';
+
+class GalleryAppBar extends ConsumerWidget {
+  final Asset asset;
+  final void Function() showInfo;
+  final void Function() onToggleMotionVideo;
+  final bool isPlayingVideo;
+
+  const GalleryAppBar({
+    super.key,
+    required this.asset,
+    required this.showInfo,
+    required this.onToggleMotionVideo,
+    required this.isPlayingVideo,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final album = ref.watch(currentAlbumProvider);
+    final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
+
+    final isPartner = ref
+        .watch(partnerSharedWithProvider)
+        .map((e) => e.isarId)
+        .contains(asset.ownerId);
+
+    toggleFavorite(Asset asset) =>
+        ref.read(assetProvider.notifier).toggleFavorite([asset]);
+
+    handleActivities() {
+      if (album != null && album.shared && album.remoteId != null) {
+        context.pushRoute(const ActivitiesRoute());
+      }
+    }
+
+    handleUpload(Asset asset) {
+      showDialog(
+        context: context,
+        builder: (BuildContext _) {
+          return UploadDialog(
+            onUpload: () {
+              ref
+                  .read(manualUploadProvider.notifier)
+                  .uploadAssets(context, [asset]);
+            },
+          );
+        },
+      );
+    }
+
+    addToAlbum(Asset addToAlbumAsset) {
+      showModalBottomSheet(
+        elevation: 0,
+        shape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(15.0),
+        ),
+        context: context,
+        builder: (BuildContext _) {
+          return AddToAlbumBottomSheet(
+            assets: [addToAlbumAsset],
+          );
+        },
+      );
+    }
+
+    return IgnorePointer(
+      ignoring: !ref.watch(showControlsProvider),
+      child: AnimatedOpacity(
+        duration: const Duration(milliseconds: 100),
+        opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
+        child: Container(
+          color: Colors.black.withOpacity(0.4),
+          child: TopControlAppBar(
+            isOwner: isOwner,
+            isPartner: isPartner,
+            isPlayingMotionVideo: isPlayingVideo,
+            asset: asset,
+            onMoreInfoPressed: showInfo,
+            onFavorite: toggleFavorite,
+            onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
+            onDownloadPressed: asset.isLocal
+                ? null
+                : () =>
+                    ref.read(imageViewerStateProvider.notifier).downloadAsset(
+                          asset,
+                          context,
+                        ),
+            onToggleMotionVideo: onToggleMotionVideo,
+            onAddToAlbumPressed: () => addToAlbum(asset),
+            onActivitiesPressed: handleActivities,
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/modules/asset_viewer/ui/video_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_controls.dart
new file mode 100644
index 0000000000..45a9372099
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/ui/video_controls.dart
@@ -0,0 +1,125 @@
+import 'dart:math';
+
+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';
+
+/// The video controls for the [videPlayerControlsProvider]
+class VideoControls extends ConsumerWidget {
+  const VideoControls({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    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: 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: 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,
+                  ),
+                ),
+                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,
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  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/ui/video_player.dart b/mobile/lib/modules/asset_viewer/ui/video_player.dart
new file mode 100644
index 0000000000..1f856e7d0f
--- /dev/null
+++ b/mobile/lib/modules/asset_viewer/ui/video_player.dart
@@ -0,0 +1,45 @@
+import 'package:chewie/chewie.dart';
+import 'package:flutter/material.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/custom_video_player_controls.dart';
+import 'package:video_player/video_player.dart';
+
+class VideoPlayerViewer extends HookConsumerWidget {
+  final VideoPlayerController controller;
+  final bool isMotionVideo;
+  final Widget? placeholder;
+  final Duration hideControlsTimer;
+  final bool showControls;
+  final bool showDownloadingIndicator;
+
+  const VideoPlayerViewer({
+    super.key,
+    required this.controller,
+    required this.isMotionVideo,
+    this.placeholder,
+    required this.hideControlsTimer,
+    required this.showControls,
+    required this.showDownloadingIndicator,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final chewie = useChewieController(
+      controller: controller,
+      controlsSafeAreaMinimum: const EdgeInsets.only(
+        bottom: 100,
+      ),
+      placeholder: SizedBox.expand(child: placeholder),
+      customControls: CustomVideoPlayerControls(
+        hideTimerDuration: hideControlsTimer,
+      ),
+      showControls: showControls && !isMotionVideo,
+      hideControlsTimer: hideControlsTimer,
+    );
+
+    return Chewie(
+      controller: chewie,
+    );
+  }
+}
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<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: 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<void> _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 dfdfb32844..2af7679a91 100644
--- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
+++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
@@ -2,46 +2,31 @@ 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';
 import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.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/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';
-import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.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/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';
 import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:isar/isar.dart';
 import 'package:openapi/api.dart' show ThumbnailFormat;
 
@@ -73,18 +58,16 @@ class GalleryViewerPage extends HookConsumerWidget {
     final settings = ref.watch(appSettingsServiceProvider);
     final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
     final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
-    final isZoomed = useState<bool>(false);
-    final isPlayingMotionVideo = useState(false);
+    final isZoomed = useState(false);
     final isPlayingVideo = useState(false);
-    Offset? localPosition;
+    final localPosition = useState<Offset?>(null);
     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;
+    // Update is playing motion video
+    ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
+      isPlayingVideo.value = state == VideoPlaybackState.playing;
+    });
+
     final stackIndex = useState(-1);
     final stack = showStack && currentAsset.stackChildrenCount > 0
         ? ref.watch(assetStackStateProvider(currentAsset))
@@ -92,30 +75,23 @@ class GalleryViewerPage extends HookConsumerWidget {
     final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
     // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
     final isFromDto = currentAsset.id == Isar.autoIncrement;
-    final album = ref.watch(currentAlbumProvider);
 
-    Asset asset() => stackIndex.value == -1
+    Asset asset = stackIndex.value == -1
         ? currentAsset
         : stackElements.elementAt(stackIndex.value);
-    final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId;
-    final isPartner = ref
-        .watch(partnerSharedWithProvider)
-        .map((e) => e.isarId)
-        .contains(asset().ownerId);
-
-    bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
 
+    final isMotionPhoto = asset.livePhotoVideoId != null;
     // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
     ref.listen(currentAssetProvider, (_, __) {});
     useEffect(
       () {
         // Delay state update to after the execution of build method
         Future.microtask(
-          () => ref.read(currentAssetProvider.notifier).set(asset()),
+          () => ref.read(currentAssetProvider.notifier).set(asset),
         );
         return null;
       },
-      [asset()],
+      [asset],
     );
 
     useEffect(
@@ -124,15 +100,11 @@ class GalleryViewerPage extends HookConsumerWidget {
             settings.getSetting<bool>(AppSettingsEnum.loadPreview);
         isLoadOriginal.value =
             settings.getSetting<bool>(AppSettingsEnum.loadOriginal);
-        isPlayingMotionVideo.value = false;
         return null;
       },
       [],
     );
 
-    void toggleFavorite(Asset asset) =>
-        ref.read(assetProvider.notifier).toggleFavorite([asset]);
-
     Future<void> precacheNextImage(int index) async {
       void onError(Object exception, StackTrace? stackTrace) {
         // swallow error silently
@@ -168,97 +140,8 @@ class GalleryViewerPage extends HookConsumerWidget {
             child: ref
                     .watch(appSettingsServiceProvider)
                     .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
-                ? AdvancedBottomSheet(assetDetail: asset())
-                : ExifBottomSheet(asset: asset()),
-          );
-        },
-      );
-    }
-
-    void removeAssetFromStack() {
-      if (stackIndex.value > 0 && showStack) {
-        ref
-            .read(assetStackStateProvider(currentAsset).notifier)
-            .removeChild(stackIndex.value - 1);
-        stackIndex.value = stackIndex.value - 1;
-      }
-    }
-
-    void handleDelete(Asset deleteAsset) async {
-      // Cannot delete readOnly / external assets. They are handled through library offline jobs
-      if (asset().isReadOnly) {
-        ImmichToast.show(
-          durationInSecond: 1,
-          context: context,
-          msg: 'asset_action_delete_err_read_only'.tr(),
-          gravity: ToastGravity.BOTTOM,
-        );
-        return;
-      }
-      Future<bool> onDelete(bool force) async {
-        final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
-          {deleteAsset},
-          force: force,
-        );
-        if (isDeleted && isParent) {
-          if (totalAssets == 1) {
-            // Handle only one asset
-            context.popRoute();
-          } else {
-            // Go to next page otherwise
-            controller.nextPage(
-              duration: const Duration(milliseconds: 100),
-              curve: Curves.fastLinearToSlowEaseIn,
-            );
-          }
-        }
-        return isDeleted;
-      }
-
-      // Asset is trashed
-      if (isTrashEnabled && !isFromTrash) {
-        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) {
-            ImmichToast.show(
-              durationInSecond: 1,
-              context: context,
-              msg: 'Asset trashed',
-              gravity: ToastGravity.BOTTOM,
-            );
-          }
-          removeAssetFromStack();
-        }
-        return;
-      }
-
-      // Asset is permanently removed
-      showDialog(
-        context: context,
-        builder: (BuildContext _) {
-          return DeleteDialog(
-            onDelete: () async {
-              final isDeleted = await onDelete(true);
-              if (isDeleted) {
-                removeAssetFromStack();
-              }
-            },
-          );
-        },
-      );
-    }
-
-    void addToAlbum(Asset addToAlbumAsset) {
-      showModalBottomSheet(
-        elevation: 0,
-        shape: RoundedRectangleBorder(
-          borderRadius: BorderRadius.circular(15.0),
-        ),
-        context: context,
-        builder: (BuildContext _) {
-          return AddToAlbumBottomSheet(
-            assets: [addToAlbumAsset],
+                ? AdvancedBottomSheet(assetDetail: asset)
+                : ExifBottomSheet(asset: asset),
           );
         },
       );
@@ -274,12 +157,12 @@ class GalleryViewerPage extends HookConsumerWidget {
       }
 
       // Guard [localPosition] null
-      if (localPosition == null) {
+      if (localPosition.value == null) {
         return;
       }
 
       // Check for delta from initial down point
-      final d = details.localPosition - localPosition!;
+      final d = details.localPosition - localPosition.value!;
       // If the magnitude of the dx swipe is large, we probably didn't mean to go down
       if (d.dx.abs() > dxThreshold) {
         return;
@@ -293,175 +176,52 @@ class GalleryViewerPage extends HookConsumerWidget {
       }
     }
 
-    shareAsset() {
-      if (asset().isOffline) {
-        ImmichToast.show(
-          durationInSecond: 1,
-          context: context,
-          msg: 'asset_action_share_err_offline'.tr(),
-          gravity: ToastGravity.BOTTOM,
-        );
-        return;
-      }
-      ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context);
-    }
+    useEffect(
+      () {
+        if (ref.read(showControlsProvider)) {
+          SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+        } else {
+          SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+        }
+        isPlayingVideo.value = false;
+        return null;
+      },
+      [],
+    );
 
-    handleArchive(Asset asset) {
-      ref.read(assetProvider.notifier).toggleArchive([asset]);
-      if (isParent) {
-        context.popRoute();
-        return;
-      }
-      removeAssetFromStack();
-    }
-
-    handleUpload(Asset asset) {
-      showDialog(
-        context: context,
-        builder: (BuildContext _) {
-          return UploadDialog(
-            onUpload: () {
-              ref
-                  .read(manualUploadProvider.notifier)
-                  .uploadAssets(context, [asset]);
-            },
-          );
-        },
-      );
-    }
-
-    handleDownload() {
-      if (asset().isLocal) {
-        return;
-      }
-      if (asset().isOffline) {
-        ImmichToast.show(
-          durationInSecond: 1,
-          context: context,
-          msg: 'asset_action_share_err_offline'.tr(),
-          gravity: ToastGravity.BOTTOM,
-        );
-        return;
-      }
-
-      ref.read(imageViewerStateProvider.notifier).downloadAsset(
-            asset(),
-            context,
-          );
-    }
-
-    handleActivities() {
-      if (album != null && album.shared && album.remoteId != null) {
-        context.pushRoute(const ActivitiesRoute());
-      }
-    }
-
-    buildAppBar() {
-      return IgnorePointer(
-        ignoring: !ref.watch(showControlsProvider),
-        child: AnimatedOpacity(
-          duration: const Duration(milliseconds: 100),
-          opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
-          child: Container(
-            color: Colors.black.withOpacity(0.4),
-            child: TopControlAppBar(
-              isOwner: isOwner,
-              isPartner: isPartner,
-              isPlayingMotionVideo: isPlayingMotionVideo.value,
-              asset: asset(),
-              onMoreInfoPressed: showInfo,
-              onFavorite: toggleFavorite,
-              onUploadPressed:
-                  asset().isLocal ? () => handleUpload(asset()) : null,
-              onDownloadPressed: asset().isLocal
-                  ? null
-                  : () =>
-                      ref.read(imageViewerStateProvider.notifier).downloadAsset(
-                            asset(),
-                            context,
-                          ),
-              onToggleMotionVideo: (() {
-                isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
-              }),
-              onAddToAlbumPressed: () => addToAlbum(asset()),
-              onActivitiesPressed: handleActivities,
-            ),
+    useEffect(
+      () {
+        // No need to await this
+        unawaited(
+          // Delay this a bit so we can finish loading the page
+          Future.delayed(const Duration(milliseconds: 400)).then(
+            // Precache the next image
+            (_) => precacheNextImage(currentIndex.value + 1),
           ),
-        ),
-      );
-    }
+        );
+        return null;
+      },
+      [],
+    );
 
-    Widget buildProgressBar() {
-      final playerValue = ref.watch(videoPlaybackValueProvider);
-
-      return Expanded(
-        child: Slider(
-          value: playerValue.duration == Duration.zero
-              ? 0.0
-              : min(
-                  playerValue.position.inMicroseconds /
-                      playerValue.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 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,
-      );
-    }
+    ref.listen(showControlsProvider, (_, show) {
+      if (show) {
+        SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+      } else {
+        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+      }
+    });
 
     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(
@@ -495,246 +255,6 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
     }
 
-    void showStackActionItems() {
-      showModalBottomSheet<void>(
-        context: context,
-        enableDrag: false,
-        builder: (BuildContext ctx) {
-          return SafeArea(
-            child: Padding(
-              padding: const EdgeInsets.only(top: 24.0),
-              child: Column(
-                mainAxisSize: MainAxisSize.min,
-                children: [
-                  if (!isParent)
-                    ListTile(
-                      leading: const Icon(
-                        Icons.bookmark_border_outlined,
-                        size: 24,
-                      ),
-                      onTap: () async {
-                        await ref
-                            .read(assetStackServiceProvider)
-                            .updateStackParent(
-                              currentAsset,
-                              stackElements.elementAt(stackIndex.value),
-                            );
-                        ctx.pop();
-                        context.popRoute();
-                      },
-                      title: const Text(
-                        "viewer_stack_use_as_main_asset",
-                        style: TextStyle(fontWeight: FontWeight.bold),
-                      ).tr(),
-                    ),
-                  ListTile(
-                    leading: const Icon(
-                      Icons.copy_all_outlined,
-                      size: 24,
-                    ),
-                    onTap: () async {
-                      if (isParent) {
-                        await ref
-                            .read(assetStackServiceProvider)
-                            .updateStackParent(
-                              currentAsset,
-                              stackElements
-                                  .elementAt(1), // Next asset as parent
-                            );
-                        // Remove itself from stack
-                        await ref.read(assetStackServiceProvider).updateStack(
-                          stackElements.elementAt(1),
-                          childrenToRemove: [currentAsset],
-                        );
-                        ctx.pop();
-                        context.popRoute();
-                      } else {
-                        await ref.read(assetStackServiceProvider).updateStack(
-                          currentAsset,
-                          childrenToRemove: [
-                            stackElements.elementAt(stackIndex.value),
-                          ],
-                        );
-                        removeAssetFromStack();
-                        ctx.pop();
-                      }
-                    },
-                    title: const Text(
-                      "viewer_remove_from_stack",
-                      style: TextStyle(fontWeight: FontWeight.bold),
-                    ).tr(),
-                  ),
-                  ListTile(
-                    leading: const Icon(
-                      Icons.filter_none_outlined,
-                      size: 18,
-                    ),
-                    onTap: () async {
-                      await ref.read(assetStackServiceProvider).updateStack(
-                            currentAsset,
-                            childrenToRemove: stack,
-                          );
-                      ctx.pop();
-                      context.popRoute();
-                    },
-                    title: const Text(
-                      "viewer_unstack",
-                      style: TextStyle(fontWeight: FontWeight.bold),
-                    ).tr(),
-                  ),
-                ],
-              ),
-            ),
-          );
-        },
-      );
-    }
-
-    // TODO: Migrate to a custom bottom bar and handle long press to delete
-    Widget buildBottomBar() {
-      // !!!! itemsList and actionlist should always be in sync
-      final itemsList = [
-        BottomNavigationBarItem(
-          icon: Icon(
-            Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
-          ),
-          label: 'control_bottom_app_bar_share'.tr(),
-          tooltip: 'control_bottom_app_bar_share'.tr(),
-        ),
-        if (isOwner)
-          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(),
-                ),
-        if (isOwner && stack.isNotEmpty)
-          BottomNavigationBarItem(
-            icon: const Icon(Icons.burst_mode_outlined),
-            label: 'control_bottom_app_bar_stack'.tr(),
-            tooltip: 'control_bottom_app_bar_stack'.tr(),
-          ),
-        if (isOwner)
-          BottomNavigationBarItem(
-            icon: const Icon(Icons.delete_outline),
-            label: 'control_bottom_app_bar_delete'.tr(),
-            tooltip: 'control_bottom_app_bar_delete'.tr(),
-          ),
-        if (!isOwner)
-          BottomNavigationBarItem(
-            icon: const Icon(Icons.download_outlined),
-            label: 'download'.tr(),
-            tooltip: 'download'.tr(),
-          ),
-      ];
-
-      List<Function(int)> actionslist = [
-        (_) => shareAsset(),
-        if (isOwner) (_) => handleArchive(asset()),
-        if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
-        if (isOwner) (_) => handleDelete(asset()),
-        if (!isOwner) (_) => handleDownload(),
-      ];
-
-      return IgnorePointer(
-        ignoring: !ref.watch(showControlsProvider),
-        child: AnimatedOpacity(
-          duration: const Duration(milliseconds: 100),
-          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: !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(),
-                      ],
-                    ),
-                  ),
-                ),
-              ),
-              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: itemsList,
-                onTap: (index) {
-                  if (index < actionslist.length) {
-                    actionslist[index].call(index);
-                  }
-                },
-              ),
-            ],
-          ),
-        ),
-      );
-    }
-
-    useEffect(
-      () {
-        if (ref.read(showControlsProvider)) {
-          SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
-        } else {
-          SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
-        }
-        return null;
-      },
-      [],
-    );
-
-    useEffect(
-      () {
-        // No need to await this
-        unawaited(
-          // Delay this a bit so we can finish loading the page
-          Future.delayed(const Duration(milliseconds: 400)).then(
-            // Precache the next image
-            (_) => precacheNextImage(currentIndex.value + 1),
-          ),
-        );
-        return null;
-      },
-      [],
-    );
-
-    ref.listen(showControlsProvider, (_, show) {
-      if (show) {
-        SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
-      } else {
-        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
-      }
-    });
-
     return PopScope(
       canPop: false,
       onPopInvoked: (_) {
@@ -762,7 +282,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                       ),
                     ),
                     ImmichThumbnail(
-                      asset: asset(),
+                      asset: asset,
                       fit: BoxFit.contain,
                     ),
                   ],
@@ -782,6 +302,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                 HapticFeedback.selectionClick();
                 currentIndex.value = value;
                 stackIndex.value = -1;
+                isPlayingVideo.value = false;
 
                 // Wait for page change animation to finish
                 await Future.delayed(const Duration(milliseconds: 400));
@@ -790,14 +311,14 @@ class GalleryViewerPage extends HookConsumerWidget {
               },
               builder: (context, index) {
                 final a =
-                    index == currentIndex.value ? asset() : loadAsset(index);
+                    index == currentIndex.value ? asset : loadAsset(index);
                 final ImageProvider provider =
                     ImmichImage.imageProvider(asset: a);
 
-                if (a.isImage && !isPlayingMotionVideo.value) {
+                if (a.isImage && !isPlayingVideo.value) {
                   return PhotoViewGalleryPageOptions(
                     onDragStart: (_, details, __) =>
-                        localPosition = details.localPosition,
+                        localPosition.value = details.localPosition,
                     onDragUpdate: (_, details, __) =>
                         handleSwipeUpDown(details),
                     onTapDown: (_, __, ___) {
@@ -821,7 +342,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                 } else {
                   return PhotoViewGalleryPageOptions.customChild(
                     onDragStart: (_, details, __) =>
-                        localPosition = details.localPosition,
+                        localPosition.value = details.localPosition,
                     onDragUpdate: (_, details, __) =>
                         handleSwipeUpDown(details),
                     heroAttributes: PhotoViewHeroAttributes(
@@ -834,15 +355,9 @@ 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,
+                      isMotionVideo: a.livePhotoVideoId != null,
                       placeholder: Image(
                         image: provider,
                         fit: BoxFit.contain,
@@ -850,11 +365,6 @@ class GalleryViewerPage extends HookConsumerWidget {
                         width: context.width,
                         alignment: Alignment.center,
                       ),
-                      onVideoEnded: () {
-                        if (isPlayingMotionVideo.value) {
-                          isPlayingMotionVideo.value = false;
-                        }
-                      },
                     ),
                   );
                 }
@@ -864,50 +374,41 @@ class GalleryViewerPage extends HookConsumerWidget {
               top: 0,
               left: 0,
               right: 0,
-              child: buildAppBar(),
+              child: GalleryAppBar(
+                asset: asset,
+                showInfo: showInfo,
+                isPlayingVideo: isPlayingVideo.value,
+                onToggleMotionVideo: () =>
+                    isPlayingVideo.value = !isPlayingVideo.value,
+              ),
             ),
             Positioned(
               bottom: 0,
               left: 0,
               right: 0,
-              child: buildBottomBar(),
+              child: Column(
+                children: [
+                  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 && !isMotionPhoto,
+                  ),
+                ],
+              ),
             ),
           ],
         ),
       ),
     );
   }
-
-  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 0da2bc52db..22f00c001d 100644
--- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
+++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
@@ -1,21 +1,22 @@
 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:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
-import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.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_controller_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/video_player.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
 
 @RoutePage()
 // ignore: must_be_immutable
-class VideoViewerPage extends HookWidget {
+class VideoViewerPage extends HookConsumerWidget {
   final Asset asset;
   final bool isMotionVideo;
   final Widget? placeholder;
-  final VoidCallback? onVideoEnded;
-  final VoidCallback? onPlaying;
-  final VoidCallback? onPaused;
   final Duration hideControlsTimer;
   final bool showControls;
   final bool showDownloadingIndicator;
@@ -24,9 +25,6 @@ class VideoViewerPage extends HookWidget {
     super.key,
     required this.asset,
     this.isMotionVideo = false,
-    this.onVideoEnded,
-    this.onPlaying,
-    this.onPaused,
     this.placeholder,
     this.showControls = true,
     this.hideControlsTimer = const Duration(seconds: 5),
@@ -34,29 +32,107 @@ class VideoViewerPage extends HookWidget {
   });
 
   @override
-  Widget build(BuildContext context) {
-    final controller = useChewieController(
-      asset,
-      controlsSafeAreaMinimum: const EdgeInsets.only(
-        bottom: 100,
-      ),
-      placeholder: placeholder,
-      showControls: showControls && !isMotionVideo,
-      hideControlsTimer: hideControlsTimer,
-      customControls: const VideoPlayerControls(),
-      onPlaying: onPlaying,
-      onPaused: onPaused,
-      onVideoEnded: onVideoEnded,
+  build(BuildContext context, WidgetRef ref) {
+    final controller =
+        ref.watch(videoPlayerControllerProvider(asset: asset)).value;
+    // The last volume of the video used when mute is toggled
+    final lastVolume = useState(0.5);
+
+    // 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) {
+      if (controller == null) {
+        // No seeeking if there is no video
+        return;
+      }
+
+      // Find the position to seek to
+      final Duration seek = controller.value.duration * (position / 100.0);
+      controller.seekTo(seek);
+    });
+
+    // When the custom video controls paus or plays
+    ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
+        (lastPause, 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() {
+      final videoPlayback = VideoPlaybackValue.fromController(controller);
+      ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
+      final state = videoPlayback.state;
+
+      // Enable the WakeLock while the video is playing
+      if (state == VideoPlaybackState.playing) {
+        // Sync with the controls playing
+        WakelockPlus.enable();
+      } else {
+        // Sync with the controls pause
+        WakelockPlus.disable();
+      }
+    }
+
+    // Adds and removes the listener to the video player
+    useEffect(
+      () {
+        Future.microtask(
+          () => ref.read(videoPlayerControlsProvider.notifier).reset(),
+        );
+        // Guard no controller
+        if (controller == null) {
+          return null;
+        }
+
+        // Hide the controls
+        // Done in a microtask to avoid setting the state while the is building
+        if (!isMotionVideo) {
+          Future.microtask(() {
+            ref.read(showControlsProvider.notifier).show = false;
+          });
+        }
+
+        // Subscribes to listener
+        controller.addListener(updateVideoPlayback);
+        return () {
+          // Removes listener when we dispose
+          controller.removeListener(updateVideoPlayback);
+          controller.pause();
+        };
+      },
+      [controller],
     );
 
-    // Loading
+    final size = MediaQuery.sizeOf(context);
+
     return PopScope(
+      onPopInvoked: (pop) {
+        ref.read(videoPlaybackValueProvider.notifier).value =
+            VideoPlaybackValue.uninitialized();
+      },
       child: AnimatedSwitcher(
         duration: const Duration(milliseconds: 400),
-        child: Builder(
-          builder: (context) {
-            if (controller == null) {
-              return Stack(
+        child: Stack(
+          children: [
+            Visibility(
+              visible: controller == null,
+              child: Stack(
                 children: [
                   if (placeholder != null) placeholder!,
                   const Positioned.fill(
@@ -67,18 +143,22 @@ class VideoViewerPage extends HookWidget {
                     ),
                   ),
                 ],
-              );
-            }
-
-            final size = MediaQuery.of(context).size;
-            return SizedBox(
-              height: size.height,
-              width: size.width,
-              child: Chewie(
-                controller: controller,
               ),
-            );
-          },
+            ),
+            if (controller != null)
+              SizedBox(
+                height: size.height,
+                width: size.width,
+                child: VideoPlayerViewer(
+                  controller: controller,
+                  isMotionVideo: isMotionVideo,
+                  placeholder: placeholder,
+                  hideControlsTimer: hideControlsTimer,
+                  showControls: showControls,
+                  showDownloadingIndicator: showDownloadingIndicator,
+                ),
+              ),
+          ],
         ),
       ),
     );
diff --git a/mobile/lib/modules/map/providers/map_state.provider.g.dart b/mobile/lib/modules/map/providers/map_state.provider.g.dart
index ca75292e7892745a1c35f420ca38d7bc2fce6e39..d1b3e54b7106960d68af50432c0a05b216dd1374 100644
GIT binary patch
delta 53
zcmZ3=zLb4~8l#4piGf9mnW0%~vSnJbL5g8onvq$mk*S%bk%^&svWc;&fk|4Lxv9x!
IKgMt-0Ey!cApigX

delta 53
zcmZ3=zLb4~8l#4BlBt1ZlBs!;xru>6l7Xd#p?Ruha$;(#WlCyNnuVpMp}A3VimB0N
IKgMt-0FH|ev;Y7A

diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart
index af57c272ae..5a316db279 100644
--- a/mobile/lib/modules/memories/ui/memory_card.dart
+++ b/mobile/lib/modules/memories/ui/memory_card.dart
@@ -69,14 +69,16 @@ class MemoryCard extends StatelessWidget {
                 return Hero(
                   tag: 'memory-${asset.id}',
                   child: VideoViewerPage(
+                    key: ValueKey(asset),
                     asset: asset,
                     showDownloadingIndicator: false,
-                    placeholder: ImmichImage(
-                      asset,
-                      fit: fit,
+                    placeholder: SizedBox.expand(
+                      child: ImmichImage(
+                        asset,
+                        fit: fit,
+                      ),
                     ),
                     hideControlsTimer: const Duration(seconds: 2),
-                    onVideoEnded: onVideoEnded,
                     showControls: false,
                   ),
                 );
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index 16ac5efb0e..64bd492a77 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -350,9 +350,6 @@ abstract class _$AppRouter extends RootStackRouter {
           key: args.key,
           asset: args.asset,
           isMotionVideo: args.isMotionVideo,
-          onVideoEnded: args.onVideoEnded,
-          onPlaying: args.onPlaying,
-          onPaused: args.onPaused,
           placeholder: args.placeholder,
           showControls: args.showControls,
           hideControlsTimer: args.hideControlsTimer,
@@ -1388,12 +1385,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
     Key? key,
     required Asset asset,
     bool isMotionVideo = false,
-    void Function()? onVideoEnded,
-    void Function()? onPlaying,
-    void Function()? onPaused,
     Widget? placeholder,
     bool showControls = true,
-    Duration hideControlsTimer = const Duration(milliseconds: 1500),
+    Duration hideControlsTimer = const Duration(seconds: 5),
     bool showDownloadingIndicator = true,
     List<PageRouteInfo>? children,
   }) : super(
@@ -1402,9 +1396,6 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
             key: key,
             asset: asset,
             isMotionVideo: isMotionVideo,
-            onVideoEnded: onVideoEnded,
-            onPlaying: onPlaying,
-            onPaused: onPaused,
             placeholder: placeholder,
             showControls: showControls,
             hideControlsTimer: hideControlsTimer,
@@ -1424,12 +1415,9 @@ class VideoViewerRouteArgs {
     this.key,
     required this.asset,
     this.isMotionVideo = false,
-    this.onVideoEnded,
-    this.onPlaying,
-    this.onPaused,
     this.placeholder,
     this.showControls = true,
-    this.hideControlsTimer = const Duration(milliseconds: 1500),
+    this.hideControlsTimer = const Duration(seconds: 5),
     this.showDownloadingIndicator = true,
   });
 
@@ -1439,12 +1427,6 @@ class VideoViewerRouteArgs {
 
   final bool isMotionVideo;
 
-  final void Function()? onVideoEnded;
-
-  final void Function()? onPlaying;
-
-  final void Function()? onPaused;
-
   final Widget? placeholder;
 
   final bool showControls;
@@ -1455,6 +1437,6 @@ class VideoViewerRouteArgs {
 
   @override
   String toString() {
-    return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}';
+    return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}';
   }
 }
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<RestartableTimer> {
+  final Duration duration;
+  final void Function() callback;
+
+  const _TimerHook({
+    required this.duration,
+    required this.callback,
+  });
+  @override
+  HookState<RestartableTimer, Hook<RestartableTimer>> createState() =>
+      _TimerHookState();
+}
+
+class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
+  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