diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 506ee9d1a4..0ec511d9f1 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) {
 }
 
 android {
-    compileSdkVersion 34
+    compileSdkVersion 35
 
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_17
@@ -47,7 +47,7 @@ android {
     defaultConfig {
         applicationId "app.alextran.immich"
         minSdkVersion 26
-        targetSdkVersion 34
+        targetSdkVersion 35
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName
     }
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index c85ce13684..8f239015dd 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -35,7 +35,7 @@
 
     <meta-data
       android:name="io.flutter.embedding.android.EnableImpeller"
-      android:value="false" />
+      android:value="true" />
 
     <meta-data
       android:name="com.google.firebase.messaging.default_notification_icon"
diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle
index 7a39a8d3cc..bcf3daa1c8 100644
--- a/mobile/android/build.gradle
+++ b/mobile/android/build.gradle
@@ -16,8 +16,8 @@ subprojects {
         if (project.plugins.hasPlugin("com.android.application") ||
                 project.plugins.hasPlugin("com.android.library")) {
             project.android {
-                compileSdkVersion 34
-                buildToolsVersion "34.0.0"
+                compileSdkVersion 35
+                buildToolsVersion "35.0.0"
             }
         }
     }
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index 55d8e0fa00..2e71937a84 100644
--- a/mobile/ios/Podfile.lock
+++ b/mobile/ios/Podfile.lock
@@ -65,6 +65,8 @@ PODS:
   - maplibre_gl (0.0.1):
     - Flutter
     - MapLibre (= 5.14.0-pre3)
+  - native_video_player (1.0.0):
+    - Flutter
   - package_info_plus (0.4.5):
     - Flutter
   - path_provider_foundation (0.0.1):
@@ -93,9 +95,6 @@ PODS:
   - Toast (4.0.0)
   - url_launcher_ios (0.0.1):
     - Flutter
-  - video_player_avfoundation (0.0.1):
-    - Flutter
-    - FlutterMacOS
   - wakelock_plus (0.0.1):
     - Flutter
 
@@ -115,6 +114,7 @@ DEPENDENCIES:
   - integration_test (from `.symlinks/plugins/integration_test/ios`)
   - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
   - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
+  - native_video_player (from `.symlinks/plugins/native_video_player/ios`)
   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
   - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
@@ -124,7 +124,6 @@ DEPENDENCIES:
   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
   - sqflite (from `.symlinks/plugins/sqflite/darwin`)
   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
-  - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
   - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
 
 SPEC REPOS:
@@ -168,6 +167,8 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/isar_flutter_libs/ios"
   maplibre_gl:
     :path: ".symlinks/plugins/maplibre_gl/ios"
+  native_video_player:
+    :path: ".symlinks/plugins/native_video_player/ios"
   package_info_plus:
     :path: ".symlinks/plugins/package_info_plus/ios"
   path_provider_foundation:
@@ -186,15 +187,13 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/sqflite/darwin"
   url_launcher_ios:
     :path: ".symlinks/plugins/url_launcher_ios/ios"
-  video_player_avfoundation:
-    :path: ".symlinks/plugins/video_player_avfoundation/darwin"
   wakelock_plus:
     :path: ".symlinks/plugins/wakelock_plus/ios"
 
 SPEC CHECKSUMS:
   background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
   connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
-  device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
+  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
   file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
@@ -210,20 +209,20 @@ SPEC CHECKSUMS:
   isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
   MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
   maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
-  package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
+  native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
+  package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
   path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
   permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
   photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
   SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
-  share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
+  share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
   shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
   sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
   url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
-  video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
   wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
 
 PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index 05cb061ca5..446c82e78f 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -1,48 +1,59 @@
-import UIKit
-import shared_preferences_foundation
-import Flutter
 import BackgroundTasks
+import Flutter
+import UIKit
 import path_provider_ios
-import photo_manager
 import permission_handler_apple
+import photo_manager
+import shared_preferences_foundation
 
 @main
 @objc class AppDelegate: FlutterAppDelegate {
-    
+
   override func application(
     _ application: UIApplication,
     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
   ) -> Bool {
 
-      // Required for flutter_local_notification
-      if #available(iOS 10.0, *) {
-        UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
+    // Required for flutter_local_notification
+    if #available(iOS 10.0, *) {
+      UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
+    }
+
+    do {
+      try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
+      try AVAudioSession.sharedInstance().setActive(true)
+    } catch {
+      print("Failed to set audio session category. Error: \(error)")
+    }
+
+    GeneratedPluginRegistrant.register(with: self)
+    BackgroundServicePlugin.registerBackgroundProcessing()
+
+    BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
+
+    BackgroundServicePlugin.setPluginRegistrantCallback { registry in
+      if !registry.hasPlugin("org.cocoapods.path-provider-ios") {
+        FLTPathProviderPlugin.register(
+          with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!)
       }
 
-      GeneratedPluginRegistrant.register(with: self)
-      BackgroundServicePlugin.registerBackgroundProcessing()
-
-      BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
-            
-      BackgroundServicePlugin.setPluginRegistrantCallback { registry in
-          if !registry.hasPlugin("org.cocoapods.path-provider-ios") {
-              FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!)
-          }
-          
-          if !registry.hasPlugin("org.cocoapods.photo-manager") {
-              PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
-          }
-          
-          if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
-              SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
-          }
-
-          if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
-              PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
-          }
+      if !registry.hasPlugin("org.cocoapods.photo-manager") {
+        PhotoManagerPlugin.register(
+          with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
       }
-      
-      return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+
+      if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
+        SharedPreferencesPlugin.register(
+          with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
+      }
+
+      if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
+        PermissionHandlerPlugin.register(
+          with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
+      }
+    }
+
+    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
   }
-    
+
 }
diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart
index a49e783602..847887de8c 100644
--- a/mobile/lib/constants/immich_colors.dart
+++ b/mobile/lib/constants/immich_colors.dart
@@ -20,8 +20,8 @@ const String defaultColorPresetName = "indigo";
 const Color immichBrandColorLight = Color(0xFF4150AF);
 const Color immichBrandColorDark = Color(0xFFACCBFA);
 const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
-const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
 const Color red400 = Color(0xFFEF5350);
+const Color grey200 = Color(0xFFEEEEEE);
 
 final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
   ImmichColorPreset.indigo: ImmichTheme(
diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart
index 182c10307f..4bec35970a 100644
--- a/mobile/lib/entities/asset.entity.dart
+++ b/mobile/lib/entities/asset.entity.dart
@@ -1,4 +1,5 @@
 import 'dart:convert';
+import 'dart:io';
 
 import 'package:immich_mobile/entities/exif_info.entity.dart';
 import 'package:immich_mobile/utils/hash.dart';
@@ -22,12 +23,8 @@ class Asset {
         durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
         type = remote.type.toAssetType(),
         fileName = remote.originalFileName,
-        height = isFlipped(remote)
-            ? remote.exifInfo?.exifImageWidth?.toInt()
-            : remote.exifInfo?.exifImageHeight?.toInt(),
-        width = isFlipped(remote)
-            ? remote.exifInfo?.exifImageHeight?.toInt()
-            : remote.exifInfo?.exifImageWidth?.toInt(),
+        height = remote.exifInfo?.exifImageHeight?.toInt(),
+        width = remote.exifInfo?.exifImageWidth?.toInt(),
         livePhotoVideoId = remote.livePhotoVideoId,
         ownerId = fastHash(remote.ownerId),
         exifInfo =
@@ -93,6 +90,27 @@ class Asset {
 
   set local(AssetEntity? assetEntity) => _local = assetEntity;
 
+  @ignore
+  bool _didUpdateLocal = false;
+
+  @ignore
+  Future<AssetEntity> get localAsync async {
+    final local = this.local;
+    if (local == null) {
+      throw Exception('Asset $fileName has no local data');
+    }
+
+    final updatedLocal =
+        _didUpdateLocal ? local : await local.obtainForNewProperties();
+    if (updatedLocal == null) {
+      throw Exception('Could not fetch local data for $fileName');
+    }
+
+    this.local = updatedLocal;
+    _didUpdateLocal = true;
+    return updatedLocal;
+  }
+
   Id id = Isar.autoIncrement;
 
   /// stores the raw SHA1 bytes as a base64 String
@@ -150,10 +168,21 @@ class Asset {
 
   int stackCount;
 
-  /// Aspect ratio of the asset
+  /// Returns null if the asset has no sync access to the exif info
   @ignore
-  double? get aspectRatio =>
-      width == null || height == null ? 0 : width! / height!;
+  double? get aspectRatio {
+    final orientatedWidth = this.orientatedWidth;
+    final orientatedHeight = this.orientatedHeight;
+
+    if (orientatedWidth != null &&
+        orientatedHeight != null &&
+        orientatedWidth > 0 &&
+        orientatedHeight > 0) {
+      return orientatedWidth.toDouble() / orientatedHeight.toDouble();
+    }
+
+    return null;
+  }
 
   /// `true` if this [Asset] is present on the device
   @ignore
@@ -172,6 +201,12 @@ class Asset {
   @ignore
   bool get isImage => type == AssetType.image;
 
+  @ignore
+  bool get isVideo => type == AssetType.video;
+
+  @ignore
+  bool get isMotionPhoto => livePhotoVideoId != null;
+
   @ignore
   AssetState get storage {
     if (isRemote && isLocal) {
@@ -192,6 +227,50 @@ class Asset {
   @ignore
   set byteHash(List<int> hash) => checksum = base64.encode(hash);
 
+  /// Returns null if the asset has no sync access to the exif info
+  @ignore
+  @pragma('vm:prefer-inline')
+  bool? get isFlipped {
+    final exifInfo = this.exifInfo;
+    if (exifInfo != null) {
+      return exifInfo.isFlipped;
+    }
+
+    if (_didUpdateLocal && Platform.isAndroid) {
+      final local = this.local;
+      if (local == null) {
+        throw Exception('Asset $fileName has no local data');
+      }
+      return local.orientation == 90 || local.orientation == 270;
+    }
+
+    return null;
+  }
+
+  /// Returns null if the asset has no sync access to the exif info
+  @ignore
+  @pragma('vm:prefer-inline')
+  int? get orientatedHeight {
+    final isFlipped = this.isFlipped;
+    if (isFlipped == null) {
+      return null;
+    }
+
+    return isFlipped ? width : height;
+  }
+
+  /// Returns null if the asset has no sync access to the exif info
+  @ignore
+  @pragma('vm:prefer-inline')
+  int? get orientatedWidth {
+    final isFlipped = this.isFlipped;
+    if (isFlipped == null) {
+      return null;
+    }
+
+    return isFlipped ? height : width;
+  }
+
   @override
   bool operator ==(other) {
     if (other is! Asset) return false;
@@ -511,21 +590,3 @@ extension AssetsHelper on IsarCollection<Asset> {
     return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
   }
 }
-
-/// Returns `true` if this [int] is flipped 90° clockwise
-bool isRotated90CW(int orientation) {
-  return [7, 8, -90].contains(orientation);
-}
-
-/// Returns `true` if this [int] is flipped 270° clockwise
-bool isRotated270CW(int orientation) {
-  return [5, 6, 90].contains(orientation);
-}
-
-/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
-bool isFlipped(AssetResponseDto response) {
-  final int orientation =
-      int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
-  return orientation != 0 &&
-      (isRotated90CW(orientation) || isRotated270CW(orientation));
-}
diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart
index 63d06f5d2c..c46f3dddc1 100644
--- a/mobile/lib/entities/exif_info.entity.dart
+++ b/mobile/lib/entities/exif_info.entity.dart
@@ -23,6 +23,7 @@ class ExifInfo {
   String? state;
   String? country;
   String? description;
+  String? orientation;
 
   @ignore
   bool get hasCoordinates =>
@@ -45,6 +46,13 @@ class ExifInfo {
   @ignore
   String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
 
+  @ignore
+  bool? _isFlipped;
+
+  @ignore
+  @pragma('vm:prefer-inline')
+  bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
+
   @ignore
   double? get latitude => lat;
 
@@ -67,7 +75,8 @@ class ExifInfo {
         city = dto.city,
         state = dto.state,
         country = dto.country,
-        description = dto.description;
+        description = dto.description,
+        orientation = dto.orientation;
 
   ExifInfo({
     this.id,
@@ -87,6 +96,7 @@ class ExifInfo {
     this.state,
     this.country,
     this.description,
+    this.orientation,
   });
 
   ExifInfo copyWith({
@@ -107,6 +117,7 @@ class ExifInfo {
     String? state,
     String? country,
     String? description,
+    String? orientation,
   }) =>
       ExifInfo(
         id: id ?? this.id,
@@ -126,6 +137,7 @@ class ExifInfo {
         state: state ?? this.state,
         country: country ?? this.country,
         description: description ?? this.description,
+        orientation: orientation ?? this.orientation,
       );
 
   @override
@@ -147,7 +159,8 @@ class ExifInfo {
         city == other.city &&
         state == other.state &&
         country == other.country &&
-        description == other.description;
+        description == other.description &&
+        orientation == other.orientation;
   }
 
   @override
@@ -169,7 +182,8 @@ class ExifInfo {
       city.hashCode ^
       state.hashCode ^
       country.hashCode ^
-      description.hashCode;
+      description.hashCode ^
+      orientation.hashCode;
 
   @override
   String toString() {
@@ -192,10 +206,21 @@ class ExifInfo {
   state: $state,
   country: $country,
   description: $description,
+  orientation: $orientation
 }""";
   }
 }
 
+bool _isOrientationFlipped(String? orientation) {
+  final value = orientation != null ? int.tryParse(orientation) : null;
+  if (value == null) {
+    return false;
+  }
+  final isRotated90CW = value == 5 || value == 6 || value == 90;
+  final isRotated270CW = value == 7 || value == 8 || value == -90;
+  return isRotated90CW || isRotated270CW;
+}
+
 double? _exposureTimeToSeconds(String? s) {
   if (s == null) {
     return null;
diff --git a/mobile/lib/entities/exif_info.entity.g.dart b/mobile/lib/entities/exif_info.entity.g.dart
index 015983abf2..0b744e5f20 100644
--- a/mobile/lib/entities/exif_info.entity.g.dart
+++ b/mobile/lib/entities/exif_info.entity.g.dart
@@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema(
       name: r'model',
       type: IsarType.string,
     ),
-    r'state': PropertySchema(
+    r'orientation': PropertySchema(
       id: 14,
+      name: r'orientation',
+      type: IsarType.string,
+    ),
+    r'state': PropertySchema(
+      id: 15,
       name: r'state',
       type: IsarType.string,
     ),
     r'timeZone': PropertySchema(
-      id: 15,
+      id: 16,
       name: r'timeZone',
       type: IsarType.string,
     )
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
       bytesCount += 3 + value.length * 3;
     }
   }
+  {
+    final value = object.orientation;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   {
     final value = object.state;
     if (value != null) {
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
   writer.writeString(offsets[11], object.make);
   writer.writeFloat(offsets[12], object.mm);
   writer.writeString(offsets[13], object.model);
-  writer.writeString(offsets[14], object.state);
-  writer.writeString(offsets[15], object.timeZone);
+  writer.writeString(offsets[14], object.orientation);
+  writer.writeString(offsets[15], object.state);
+  writer.writeString(offsets[16], object.timeZone);
 }
 
 ExifInfo _exifInfoDeserialize(
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
     make: reader.readStringOrNull(offsets[11]),
     mm: reader.readFloatOrNull(offsets[12]),
     model: reader.readStringOrNull(offsets[13]),
-    state: reader.readStringOrNull(offsets[14]),
-    timeZone: reader.readStringOrNull(offsets[15]),
+    orientation: reader.readStringOrNull(offsets[14]),
+    state: reader.readStringOrNull(offsets[15]),
+    timeZone: reader.readStringOrNull(offsets[16]),
   );
   return object;
 }
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
       return (reader.readStringOrNull(offset)) as P;
     case 15:
       return (reader.readStringOrNull(offset)) as P;
+    case 16:
+      return (reader.readStringOrNull(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
   }
@@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'orientation',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      orientationIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'orientation',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
+    String? value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'orientation',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      orientationGreaterThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'orientation',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'orientation',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
+    String? lower,
+    String? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'orientation',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'orientation',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'orientation',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
+      String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'orientation',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'orientation',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'orientation',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      orientationIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'orientation',
+        value: '',
+      ));
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(const FilterCondition.isNull(
@@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'orientation', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'orientation', Sort.desc);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'state', Sort.asc);
@@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'orientation', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'orientation', Sort.desc);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'state', Sort.asc);
@@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
@@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty
     });
   }
 
+  QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'orientation');
+    });
+  }
+
   QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'state');
diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart
new file mode 100644
index 0000000000..5bbd73163a
--- /dev/null
+++ b/mobile/lib/extensions/scroll_extensions.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/cupertino.dart';
+
+// https://stackoverflow.com/a/74453792
+class FastScrollPhysics extends ScrollPhysics {
+  const FastScrollPhysics({super.parent});
+
+  @override
+  FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
+    return FastScrollPhysics(parent: buildParent(ancestor));
+  }
+
+  @override
+  SpringDescription get spring => const SpringDescription(
+        mass: 40,
+        stiffness: 100,
+        damping: 1,
+      );
+}
+
+class FastClampingScrollPhysics extends ClampingScrollPhysics {
+  const FastClampingScrollPhysics({super.parent});
+
+  @override
+  FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
+    return FastClampingScrollPhysics(parent: buildParent(ancestor));
+  }
+
+  @override
+  SpringDescription get spring => const SpringDescription(
+        // When swiping between videos on Android, the placeholder of the first opened video
+        // can briefly be seen and cause a flicker effect if the video begins to initialize
+        // before the animation finishes - probably a bug in PhotoViewGallery's animation handling
+        // Making the animation faster is not just stylistic, but also helps to avoid this flicker
+        mass: 80,
+        stiffness: 100,
+        damping: 1,
+      );
+}
diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart
new file mode 100644
index 0000000000..eafc325049
--- /dev/null
+++ b/mobile/lib/pages/common/gallery_stacked_children.dart
@@ -0,0 +1,91 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
+import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
+
+class GalleryStackedChildren extends HookConsumerWidget {
+  final ValueNotifier<int> stackIndex;
+
+  const GalleryStackedChildren(this.stackIndex, {super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final asset = ref.watch(currentAssetProvider);
+    if (asset == null) {
+      return const SizedBox();
+    }
+
+    final stackId = asset.stackId;
+    if (stackId == null) {
+      return const SizedBox();
+    }
+
+    final stackElements = ref.watch(assetStackStateProvider(stackId));
+    final showControls = ref.watch(showControlsProvider);
+
+    return IgnorePointer(
+      ignoring: !showControls,
+      child: AnimatedOpacity(
+        duration: const Duration(milliseconds: 100),
+        opacity: showControls ? 1.0 : 0.0,
+        child: SizedBox(
+          height: 80,
+          child: ListView.builder(
+            shrinkWrap: true,
+            scrollDirection: Axis.horizontal,
+            itemCount: stackElements.length,
+            padding: const EdgeInsets.only(
+              left: 5,
+              right: 5,
+              bottom: 30,
+            ),
+            itemBuilder: (context, index) {
+              final currentAsset = stackElements.elementAt(index);
+              final assetId = currentAsset.remoteId;
+              if (assetId == null) {
+                return const SizedBox();
+              }
+
+              return Padding(
+                key: ValueKey(currentAsset.id),
+                padding: const EdgeInsets.only(right: 5),
+                child: GestureDetector(
+                  onTap: () {
+                    stackIndex.value = index;
+                    ref.read(currentAssetProvider.notifier).set(currentAsset);
+                  },
+                  child: Container(
+                    width: 60,
+                    height: 60,
+                    decoration: index == stackIndex.value
+                        ? const BoxDecoration(
+                            color: Colors.white,
+                            borderRadius: BorderRadius.all(Radius.circular(6)),
+                            border: Border.fromBorderSide(
+                              BorderSide(color: Colors.white, width: 2),
+                            ),
+                          )
+                        : const BoxDecoration(
+                            color: Colors.white,
+                            borderRadius: BorderRadius.all(Radius.circular(6)),
+                            border: null,
+                          ),
+                    child: ClipRRect(
+                      borderRadius: const BorderRadius.all(Radius.circular(4)),
+                      child: Image(
+                        fit: BoxFit.cover,
+                        image: ImmichRemoteImageProvider(assetId: assetId),
+                      ),
+                    ),
+                  ),
+                ),
+              );
+            },
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart
index 5747332587..2ea446ea71 100644
--- a/mobile/lib/pages/common/gallery_viewer.page.dart
+++ b/mobile/lib/pages/common/gallery_viewer.page.dart
@@ -8,18 +8,19 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/constants.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/extensions/scroll_extensions.dart';
 import 'package:immich_mobile/pages/common/download_panel.dart';
-import 'package:immich_mobile/pages/common/video_viewer.page.dart';
+import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
+import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
 import 'package:immich_mobile/providers/app_settings.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
 import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
-import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
 import 'package:immich_mobile/services/app_settings.service.dart';
 import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
@@ -35,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri
 
 @RoutePage()
 // ignore: must_be_immutable
+/// Expects [currentAssetProvider] to be set before navigating to this page
 class GalleryViewerPage extends HookConsumerWidget {
   final int initialIndex;
   final int heroOffset;
@@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final settings = ref.watch(appSettingsServiceProvider);
-    final loadAsset = renderList.loadAsset;
     final totalAssets = useState(renderList.totalAssets);
-    final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
     final isZoomed = useState(false);
-    final isPlayingVideo = useState(false);
-    final localPosition = useState<Offset?>(null);
-    final currentIndex = useState(initialIndex);
-    final currentAsset = loadAsset(currentIndex.value);
-
-    // 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.stackCount > 0
-        ? ref.watch(assetStackStateProvider(currentAsset))
-        : <Asset>[];
-    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 == noDbId;
-
-    Asset asset = stackIndex.value == -1
-        ? currentAsset
-        : stackElements.elementAt(stackIndex.value);
-
-    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),
-        );
-        return null;
-      },
-      [asset],
-    );
-
-    useEffect(
-      () {
-        shouldLoopVideo.value =
-            settings.getSetting<bool>(AppSettingsEnum.loopVideo);
-        return null;
-      },
-      [],
-    );
+    final stackIndex = useState(0);
+    final localPosition = useRef<Offset?>(null);
+    final currentIndex = useValueNotifier(initialIndex);
+    final loadAsset = renderList.loadAsset;
 
     Future<void> precacheNextImage(int index) async {
+      if (!context.mounted) {
+        return;
+      }
+
       void onError(Object exception, StackTrace? stackTrace) {
         // swallow error silently
-        debugPrint('Error precaching next image: $exception, $stackTrace');
+        log.severe('Error precaching next image: $exception, $stackTrace');
       }
 
       try {
         if (index < totalAssets.value && index >= 0) {
           final asset = loadAsset(index);
           await precacheImage(
-            ImmichImage.imageProvider(asset: asset),
+            ImmichImage.imageProvider(
+              asset: asset,
+              width: context.width,
+              height: context.height,
+            ),
             context,
             onError: onError,
           );
         }
       } catch (e) {
         // swallow error silently
-        debugPrint('Error precaching next image: $e');
+        log.severe('Error precaching next image: $e');
         context.maybePop();
       }
     }
 
+    useEffect(
+      () {
+        if (ref.read(showControlsProvider)) {
+          SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+        } else {
+          SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+        }
+
+        // Delay this a bit so we can finish loading the page
+        Timer(const Duration(milliseconds: 400), () {
+          precacheNextImage(currentIndex.value + 1);
+        });
+
+        return null;
+      },
+      const [],
+    );
+
     void showInfo() {
+      final asset = ref.read(currentAssetProvider);
+      if (asset == null) {
+        return;
+      }
       showModalBottomSheet(
         shape: const RoundedRectangleBorder(
           borderRadius: BorderRadius.all(Radius.circular(15.0)),
@@ -183,86 +172,100 @@ class GalleryViewerPage extends HookConsumerWidget {
       }
     }
 
-    useEffect(
-      () {
-        if (ref.read(showControlsProvider)) {
-          SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
-        } else {
-          SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
-        }
-        isPlayingVideo.value = false;
-        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) {
+      if (show || Platform.isIOS) {
         SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
-      } else {
-        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+        return;
       }
+
+      // This prevents the bottom bar from "dropping" while the controls are being hidden
+      Timer(const Duration(milliseconds: 100), () {
+        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+      });
     });
 
-    Widget buildStackedChildren() {
-      return ListView.builder(
-        shrinkWrap: true,
-        scrollDirection: Axis.horizontal,
-        itemCount: stackElements.length,
-        padding: const EdgeInsets.only(
-          left: 5,
-          right: 5,
-          bottom: 30,
-        ),
-        itemBuilder: (context, index) {
-          final assetId = stackElements.elementAt(index).remoteId;
-          return Padding(
-            padding: const EdgeInsets.only(right: 5),
-            child: GestureDetector(
-              onTap: () => stackIndex.value = index,
-              child: Container(
-                width: 60,
-                height: 60,
-                decoration: BoxDecoration(
-                  color: Colors.white,
-                  borderRadius: BorderRadius.circular(6),
-                  border: (stackIndex.value == -1 && index == 0) ||
-                          index == stackIndex.value
-                      ? Border.all(
-                          color: Colors.white,
-                          width: 2,
-                        )
-                      : null,
-                ),
-                child: ClipRRect(
-                  borderRadius: BorderRadius.circular(4),
-                  child: Image(
-                    fit: BoxFit.cover,
-                    image: ImmichRemoteImageProvider(assetId: assetId!),
-                  ),
-                ),
-              ),
-            ),
-          );
+    PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
+      return PhotoViewGalleryPageOptions(
+        onDragStart: (_, details, __) {
+          localPosition.value = details.localPosition;
         },
+        onDragUpdate: (_, details, __) {
+          handleSwipeUpDown(details);
+        },
+        onTapDown: (_, __, ___) {
+          ref.read(showControlsProvider.notifier).toggle();
+        },
+        onLongPressStart: asset.isMotionPhoto
+            ? (_, __, ___) {
+                ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
+              }
+            : null,
+        imageProvider: ImmichImage.imageProvider(asset: asset),
+        heroAttributes: _getHeroAttributes(asset),
+        filterQuality: FilterQuality.high,
+        tightMode: true,
+        minScale: PhotoViewComputedScale.contained,
+        errorBuilder: (context, error, stackTrace) => ImmichImage(
+          asset,
+          fit: BoxFit.contain,
+        ),
       );
     }
 
+    PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
+      // This key is to prevent the video player from being re-initialized during the hero animation
+      final key = GlobalKey();
+      return PhotoViewGalleryPageOptions.customChild(
+        onDragStart: (_, details, __) =>
+            localPosition.value = details.localPosition,
+        onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
+        heroAttributes: _getHeroAttributes(asset),
+        filterQuality: FilterQuality.high,
+        initialScale: 1.0,
+        maxScale: 1.0,
+        minScale: 1.0,
+        basePosition: Alignment.center,
+        child: SizedBox(
+          width: context.width,
+          height: context.height,
+          child: NativeVideoViewerPage(
+            key: key,
+            asset: asset,
+            image: Image(
+              key: ValueKey(asset),
+              image: ImmichImage.imageProvider(
+                asset: asset,
+                width: context.width,
+                height: context.height,
+              ),
+              fit: BoxFit.contain,
+              height: context.height,
+              width: context.width,
+              alignment: Alignment.center,
+            ),
+          ),
+        ),
+      );
+    }
+
+    PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
+      ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
+      var newAsset = loadAsset(index);
+      final stackId = newAsset.stackId;
+      if (stackId != null && currentIndex.value == index) {
+        final stackElements =
+            ref.read(assetStackStateProvider(newAsset.stackId!));
+        if (stackIndex.value < stackElements.length) {
+          newAsset = stackElements.elementAt(stackIndex.value);
+        }
+      }
+
+      if (newAsset.isImage && !newAsset.isMotionPhoto) {
+        return buildImage(context, newAsset);
+      }
+      return buildVideo(context, newAsset);
+    }
+
     return PopScope(
       // Change immersive mode back to normal "edgeToEdge" mode
       onPopInvokedWithResult: (didPop, _) =>
@@ -272,128 +275,79 @@ class GalleryViewerPage extends HookConsumerWidget {
         body: Stack(
           children: [
             PhotoViewGallery.builder(
+              key: const ValueKey('gallery'),
               scaleStateChangedCallback: (state) {
-                isZoomed.value = state != PhotoViewScaleState.initial;
-                ref.read(showControlsProvider.notifier).show = !isZoomed.value;
+                final asset = ref.read(currentAssetProvider);
+                if (asset == null) {
+                  return;
+                }
+
+                if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
+                  isZoomed.value = state != PhotoViewScaleState.initial;
+                  ref.read(showControlsProvider.notifier).show =
+                      !isZoomed.value;
+                }
               },
-              loadingBuilder: (context, event, index) => ClipRect(
-                child: Stack(
-                  fit: StackFit.expand,
-                  children: [
-                    BackdropFilter(
-                      filter: ui.ImageFilter.blur(
-                        sigmaX: 10,
-                        sigmaY: 10,
+              gaplessPlayback: true,
+              loadingBuilder: (context, event, index) {
+                final asset = loadAsset(index);
+                return ClipRect(
+                  child: Stack(
+                    fit: StackFit.expand,
+                    children: [
+                      BackdropFilter(
+                        filter: ui.ImageFilter.blur(
+                          sigmaX: 10,
+                          sigmaY: 10,
+                        ),
                       ),
-                    ),
-                    ImmichThumbnail(
-                      asset: asset,
-                      fit: BoxFit.contain,
-                    ),
-                  ],
-                ),
-              ),
+                      ImmichThumbnail(
+                        key: ValueKey(asset),
+                        asset: asset,
+                        fit: BoxFit.contain,
+                      ),
+                    ],
+                  ),
+                );
+              },
               pageController: controller,
               scrollPhysics: isZoomed.value
                   ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
                   : (Platform.isIOS
-                      ? const ScrollPhysics() // Use bouncing physics for iOS
-                      : const ClampingScrollPhysics() // Use heavy physics for Android
+                      ? const FastScrollPhysics() // Use bouncing physics for iOS
+                      : const FastClampingScrollPhysics() // Use heavy physics for Android
                   ),
               itemCount: totalAssets.value,
               scrollDirection: Axis.horizontal,
-              onPageChanged: (value) async {
+              onPageChanged: (value) {
                 final next = currentIndex.value < value ? value + 1 : value - 1;
 
                 ref.read(hapticFeedbackProvider.notifier).selectionClick();
 
+                final newAsset = loadAsset(value);
+
                 currentIndex.value = value;
-                stackIndex.value = -1;
-                isPlayingVideo.value = false;
+                stackIndex.value = 0;
 
-                // Wait for page change animation to finish
-                await Future.delayed(const Duration(milliseconds: 400));
-                // Then precache the next image
-                unawaited(precacheNextImage(next));
-              },
-              builder: (context, index) {
-                final a =
-                    index == currentIndex.value ? asset : loadAsset(index);
-
-                final ImageProvider provider =
-                    ImmichImage.imageProvider(asset: a);
-
-                if (a.isImage && !isPlayingVideo.value) {
-                  return PhotoViewGalleryPageOptions(
-                    onDragStart: (_, details, __) =>
-                        localPosition.value = details.localPosition,
-                    onDragUpdate: (_, details, __) =>
-                        handleSwipeUpDown(details),
-                    onTapDown: (_, __, ___) {
-                      ref.read(showControlsProvider.notifier).toggle();
-                    },
-                    onLongPressStart: (_, __, ___) {
-                      if (asset.livePhotoVideoId != null) {
-                        isPlayingVideo.value = true;
-                      }
-                    },
-                    imageProvider: provider,
-                    heroAttributes: PhotoViewHeroAttributes(
-                      tag: isFromDto
-                          ? '${currentAsset.remoteId}-$heroOffset'
-                          : currentAsset.id + heroOffset,
-                      transitionOnUserGestures: true,
-                    ),
-                    filterQuality: FilterQuality.high,
-                    tightMode: true,
-                    minScale: PhotoViewComputedScale.contained,
-                    errorBuilder: (context, error, stackTrace) => ImmichImage(
-                      a,
-                      fit: BoxFit.contain,
-                    ),
-                  );
-                } else {
-                  return PhotoViewGalleryPageOptions.customChild(
-                    onDragStart: (_, details, __) =>
-                        localPosition.value = details.localPosition,
-                    onDragUpdate: (_, details, __) =>
-                        handleSwipeUpDown(details),
-                    heroAttributes: PhotoViewHeroAttributes(
-                      tag: isFromDto
-                          ? '${currentAsset.remoteId}-$heroOffset'
-                          : currentAsset.id + heroOffset,
-                    ),
-                    filterQuality: FilterQuality.high,
-                    maxScale: 1.0,
-                    minScale: 1.0,
-                    basePosition: Alignment.center,
-                    child: VideoViewerPage(
-                      key: ValueKey(a),
-                      asset: a,
-                      isMotionVideo: a.livePhotoVideoId != null,
-                      loopVideo: shouldLoopVideo.value,
-                      placeholder: Image(
-                        image: provider,
-                        fit: BoxFit.contain,
-                        height: context.height,
-                        width: context.width,
-                        alignment: Alignment.center,
-                      ),
-                    ),
-                  );
+                ref.read(currentAssetProvider.notifier).set(newAsset);
+                if (newAsset.isVideo || newAsset.isMotionPhoto) {
+                  ref.read(videoPlaybackValueProvider.notifier).reset();
                 }
+
+                // Wait for page change animation to finish, then precache the next image
+                Timer(const Duration(milliseconds: 400), () {
+                  precacheNextImage(next);
+                });
               },
+              builder: buildAsset,
             ),
             Positioned(
               top: 0,
               left: 0,
               right: 0,
               child: GalleryAppBar(
-                asset: asset,
+                key: const ValueKey('app-bar'),
                 showInfo: showInfo,
-                isPlayingVideo: isPlayingVideo.value,
-                onToggleMotionVideo: () =>
-                    isPlayingVideo.value = !isPlayingVideo.value,
               ),
             ),
             Positioned(
@@ -402,22 +356,15 @@ class GalleryViewerPage extends HookConsumerWidget {
               right: 0,
               child: Column(
                 children: [
-                  Visibility(
-                    visible: stack.isNotEmpty,
-                    child: SizedBox(
-                      height: 80,
-                      child: buildStackedChildren(),
-                    ),
-                  ),
+                  GalleryStackedChildren(stackIndex),
                   BottomGalleryBar(
+                    key: const ValueKey('bottom-bar'),
                     renderList: renderList,
                     totalAssets: totalAssets,
                     controller: controller,
                     showStack: showStack,
-                    stackIndex: stackIndex.value,
-                    asset: asset,
+                    stackIndex: stackIndex,
                     assetIndex: currentIndex,
-                    showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
                   ),
                 ],
               ),
@@ -428,4 +375,14 @@ class GalleryViewerPage extends HookConsumerWidget {
       ),
     );
   }
+
+  @pragma('vm:prefer-inline')
+  PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
+    return PhotoViewHeroAttributes(
+      tag: asset.isInDb
+          ? asset.id + heroOffset
+          : '${asset.remoteId}-$heroOffset',
+      transitionOnUserGestures: true,
+    );
+  }
 }
diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart
new file mode 100644
index 0000000000..536c7f6303
--- /dev/null
+++ b/mobile/lib/pages/common/native_video_viewer.page.dart
@@ -0,0 +1,411 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:immich_mobile/entities/store.entity.dart';
+import 'package:immich_mobile/providers/app_settings.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
+import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
+import 'package:immich_mobile/services/asset.service.dart';
+import 'package:immich_mobile/utils/debounce.dart';
+import 'package:immich_mobile/utils/hooks/interval_hook.dart';
+import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
+import 'package:logging/logging.dart';
+import 'package:native_video_player/native_video_player.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
+
+@RoutePage()
+class NativeVideoViewerPage extends HookConsumerWidget {
+  final Asset asset;
+  final bool showControls;
+  final Widget image;
+
+  const NativeVideoViewerPage({
+    super.key,
+    required this.asset,
+    required this.image,
+    this.showControls = true,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final controller = useState<NativeVideoPlayerController?>(null);
+    final lastVideoPosition = useRef(-1);
+    final isBuffering = useRef(false);
+    final showMotionVideo = useState(false);
+
+    // When a video is opened through the timeline, `isCurrent` will immediately be true.
+    // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
+    // If the swipe is completed, `isCurrent` will be true for video B after a delay.
+    // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
+    final currentAsset = useState(ref.read(currentAssetProvider));
+    final isCurrent = currentAsset.value == asset;
+
+    // Used to show the placeholder during hero animations for remote videos to avoid a stutter
+    final isVisible =
+        useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto);
+
+    final log = Logger('NativeVideoViewerPage');
+
+    ref.listen(isPlayingMotionVideoProvider, (_, value) async {
+      final videoController = controller.value;
+      if (!asset.isMotionPhoto || videoController == null || !context.mounted) {
+        return;
+      }
+
+      showMotionVideo.value = value;
+      try {
+        if (value) {
+          await videoController.seekTo(0);
+          await videoController.play();
+        } else {
+          await videoController.pause();
+        }
+      } catch (error) {
+        log.severe('Error toggling motion video: $error');
+      }
+    });
+
+    Future<VideoSource?> createSource() async {
+      if (!context.mounted) {
+        return null;
+      }
+
+      try {
+        final local = asset.local;
+        if (local != null && !asset.isMotionPhoto) {
+          final file = await local.file;
+          if (file == null) {
+            throw Exception('No file found for the video');
+          }
+
+          final source = await VideoSource.init(
+            path: file.path,
+            type: VideoSourceType.file,
+          );
+          return source;
+        }
+
+        // Use a network URL for the video player controller
+        final serverEndpoint = Store.get(StoreKey.serverEndpoint);
+        final String videoUrl = asset.livePhotoVideoId != null
+            ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
+            : '$serverEndpoint/assets/${asset.remoteId}/video/playback';
+
+        final source = await VideoSource.init(
+          path: videoUrl,
+          type: VideoSourceType.network,
+          headers: ApiService.getRequestHeaders(),
+        );
+        return source;
+      } catch (error) {
+        log.severe(
+          'Error creating video source for asset ${asset.fileName}: $error',
+        );
+        return null;
+      }
+    }
+
+    final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
+    final aspectRatio = useState<double?>(asset.aspectRatio);
+    useMemoized(
+      () async {
+        if (!context.mounted || aspectRatio.value != null) {
+          return null;
+        }
+
+        try {
+          aspectRatio.value =
+              await ref.read(assetServiceProvider).getAspectRatio(asset);
+        } catch (error) {
+          log.severe(
+            'Error getting aspect ratio for asset ${asset.fileName}: $error',
+          );
+        }
+      },
+    );
+
+    void checkIfBuffering() {
+      if (!context.mounted) {
+        return;
+      }
+
+      final videoPlayback = ref.read(videoPlaybackValueProvider);
+      if ((isBuffering.value ||
+              videoPlayback.state == VideoPlaybackState.initializing) &&
+          videoPlayback.state != VideoPlaybackState.buffering) {
+        ref.read(videoPlaybackValueProvider.notifier).value =
+            videoPlayback.copyWith(state: VideoPlaybackState.buffering);
+      }
+    }
+
+    // Timer to mark videos as buffering if the position does not change
+    useInterval(const Duration(seconds: 5), checkIfBuffering);
+
+    // When the position changes, seek to the position
+    // Debounce the seek to avoid seeking too often
+    // But also don't delay the seek too much to maintain visual feedback
+    final seekDebouncer = useDebouncer(
+      interval: const Duration(milliseconds: 100),
+      maxWaitTime: const Duration(milliseconds: 200),
+    );
+    ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
+      final playerController = controller.value;
+      if (playerController == null) {
+        return;
+      }
+
+      final playbackInfo = playerController.playbackInfo;
+      if (playbackInfo == null) {
+        return;
+      }
+
+      final oldSeek = (oldControls?.position ?? 0) ~/ 1;
+      final newSeek = newControls.position ~/ 1;
+      if (oldSeek != newSeek || newControls.restarted) {
+        seekDebouncer.run(() => playerController.seekTo(newSeek));
+      }
+
+      if (oldControls?.pause != newControls.pause || newControls.restarted) {
+        // Make sure the last seek is complete before pausing or playing
+        // Otherwise, `onPlaybackPositionChanged` can receive outdated events
+        if (seekDebouncer.isActive) {
+          await seekDebouncer.drain();
+        }
+
+        try {
+          if (newControls.pause) {
+            await playerController.pause();
+          } else {
+            await playerController.play();
+          }
+        } catch (error) {
+          log.severe('Error pausing or playing video: $error');
+        }
+      }
+    });
+
+    void onPlaybackReady() async {
+      final videoController = controller.value;
+      if (videoController == null || !isCurrent || !context.mounted) {
+        return;
+      }
+
+      final videoPlayback =
+          VideoPlaybackValue.fromNativeController(videoController);
+      ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
+
+      try {
+        if (asset.isVideo || showMotionVideo.value) {
+          await videoController.play();
+        }
+        await videoController.setVolume(0.9);
+      } catch (error) {
+        log.severe('Error playing video: $error');
+      }
+    }
+
+    void onPlaybackStatusChanged() {
+      final videoController = controller.value;
+      if (videoController == null || !context.mounted) {
+        return;
+      }
+
+      final videoPlayback =
+          VideoPlaybackValue.fromNativeController(videoController);
+      if (videoPlayback.state == VideoPlaybackState.playing) {
+        // Sync with the controls playing
+        WakelockPlus.enable();
+      } else {
+        // Sync with the controls pause
+        WakelockPlus.disable();
+      }
+
+      ref.read(videoPlaybackValueProvider.notifier).status =
+          videoPlayback.state;
+    }
+
+    void onPlaybackPositionChanged() {
+      // When seeking, these events sometimes move the slider to an older position
+      if (seekDebouncer.isActive) {
+        return;
+      }
+
+      final videoController = controller.value;
+      if (videoController == null || !context.mounted) {
+        return;
+      }
+
+      final playbackInfo = videoController.playbackInfo;
+      if (playbackInfo == null) {
+        return;
+      }
+
+      ref.read(videoPlaybackValueProvider.notifier).position =
+          Duration(seconds: playbackInfo.position);
+
+      // Check if the video is buffering
+      if (playbackInfo.status == PlaybackStatus.playing) {
+        isBuffering.value = lastVideoPosition.value == playbackInfo.position;
+        lastVideoPosition.value = playbackInfo.position;
+      } else {
+        isBuffering.value = false;
+        lastVideoPosition.value = -1;
+      }
+    }
+
+    void onPlaybackEnded() {
+      final videoController = controller.value;
+      if (videoController == null || !context.mounted) {
+        return;
+      }
+
+      if (showMotionVideo.value &&
+          videoController.playbackInfo?.status == PlaybackStatus.stopped &&
+          !ref
+              .read(appSettingsServiceProvider)
+              .getSetting<bool>(AppSettingsEnum.loopVideo)) {
+        ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
+      }
+    }
+
+    void removeListeners(NativeVideoPlayerController controller) {
+      controller.onPlaybackPositionChanged
+          .removeListener(onPlaybackPositionChanged);
+      controller.onPlaybackStatusChanged
+          .removeListener(onPlaybackStatusChanged);
+      controller.onPlaybackReady.removeListener(onPlaybackReady);
+      controller.onPlaybackEnded.removeListener(onPlaybackEnded);
+    }
+
+    void initController(NativeVideoPlayerController nc) async {
+      if (controller.value != null || !context.mounted) {
+        return;
+      }
+      ref.read(videoPlayerControlsProvider.notifier).reset();
+      ref.read(videoPlaybackValueProvider.notifier).reset();
+
+      final source = await videoSource;
+      if (source == null) {
+        return;
+      }
+
+      nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
+      nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
+      nc.onPlaybackReady.addListener(onPlaybackReady);
+      nc.onPlaybackEnded.addListener(onPlaybackEnded);
+
+      nc.loadVideoSource(source).catchError((error) {
+        log.severe('Error loading video source: $error');
+      });
+      final loopVideo = ref
+          .read(appSettingsServiceProvider)
+          .getSetting<bool>(AppSettingsEnum.loopVideo);
+      nc.setLoop(loopVideo);
+
+      controller.value = nc;
+      Timer(const Duration(milliseconds: 200), checkIfBuffering);
+    }
+
+    ref.listen(currentAssetProvider, (_, value) {
+      final playerController = controller.value;
+      if (playerController != null && value != asset) {
+        removeListeners(playerController);
+      }
+
+      final curAsset = currentAsset.value;
+      if (curAsset == asset) {
+        return;
+      }
+
+      final imageToVideo = curAsset != null && !curAsset.isVideo;
+
+      // No need to delay video playback when swiping from an image to a video
+      if (imageToVideo && Platform.isIOS) {
+        currentAsset.value = value;
+        onPlaybackReady();
+        return;
+      }
+
+      // Delay the video playback to avoid a stutter in the swipe animation
+      Timer(
+          Platform.isIOS
+              ? const Duration(milliseconds: 300)
+              : imageToVideo
+                  ? const Duration(milliseconds: 200)
+                  : const Duration(milliseconds: 400), () {
+        if (!context.mounted) {
+          return;
+        }
+
+        currentAsset.value = value;
+        if (currentAsset.value == asset) {
+          onPlaybackReady();
+        }
+      });
+    });
+
+    useEffect(
+      () {
+        // If opening a remote video from a hero animation, delay visibility to avoid a stutter
+        final timer = isVisible.value
+            ? null
+            : Timer(
+                const Duration(milliseconds: 300),
+                () => isVisible.value = true,
+              );
+
+        return () {
+          timer?.cancel();
+          final playerController = controller.value;
+          if (playerController == null) {
+            return;
+          }
+          removeListeners(playerController);
+          playerController.stop().catchError((error) {
+            log.fine('Error stopping video: $error');
+          });
+
+          WakelockPlus.disable();
+        };
+      },
+      const [],
+    );
+
+    return Stack(
+      children: [
+        // This remains under the video to avoid flickering
+        // For motion videos, this is the image portion of the asset
+        Center(key: ValueKey(asset.id), child: image),
+        if (aspectRatio.value != null)
+          Visibility.maintain(
+            key: ValueKey(asset),
+            visible:
+                (asset.isVideo || showMotionVideo.value) && isVisible.value,
+            child: Center(
+              key: ValueKey(asset),
+              child: AspectRatio(
+                key: ValueKey(asset),
+                aspectRatio: aspectRatio.value!,
+                child: isCurrent
+                    ? NativeVideoPlayerView(
+                        key: ValueKey(asset),
+                        onViewReady: initController,
+                      )
+                    : null,
+              ),
+            ),
+          ),
+        if (showControls) const Center(child: CustomVideoPlayerControls()),
+      ],
+    );
+  }
+}
diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart
deleted file mode 100644
index 774d4eb31e..0000000000
--- a/mobile/lib/pages/common/video_viewer.page.dart
+++ /dev/null
@@ -1,167 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/entities/asset.entity.dart';
-import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
-import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart';
-import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
-import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
-import 'package:immich_mobile/widgets/asset_viewer/video_player.dart';
-import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
-import 'package:wakelock_plus/wakelock_plus.dart';
-
-class VideoViewerPage extends HookConsumerWidget {
-  final Asset asset;
-  final bool isMotionVideo;
-  final Widget? placeholder;
-  final Duration hideControlsTimer;
-  final bool showControls;
-  final bool showDownloadingIndicator;
-  final bool loopVideo;
-
-  const VideoViewerPage({
-    super.key,
-    required this.asset,
-    this.isMotionVideo = false,
-    this.placeholder,
-    this.showControls = true,
-    this.hideControlsTimer = const Duration(seconds: 5),
-    this.showDownloadingIndicator = true,
-    this.loopVideo = false,
-  });
-
-  @override
-  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
-        Future.microtask(() {
-          controller.addListener(updateVideoPlayback);
-        });
-        return () {
-          // Removes listener when we dispose
-          controller.removeListener(updateVideoPlayback);
-          controller.pause();
-        };
-      },
-      [controller],
-    );
-
-    return PopScope(
-      onPopInvokedWithResult: (didPop, _) {
-        ref.read(videoPlaybackValueProvider.notifier).value =
-            VideoPlaybackValue.uninitialized();
-      },
-      child: AnimatedSwitcher(
-        duration: const Duration(milliseconds: 400),
-        child: Stack(
-          children: [
-            Visibility(
-              visible: controller == null,
-              child: Stack(
-                children: [
-                  if (placeholder != null) placeholder!,
-                  const Positioned.fill(
-                    child: Center(
-                      child: DelayedLoadingIndicator(
-                        fadeInDuration: Duration(milliseconds: 500),
-                      ),
-                    ),
-                  ),
-                ],
-              ),
-            ),
-            if (controller != null)
-              SizedBox(
-                height: context.height,
-                width: context.width,
-                child: VideoPlayerViewer(
-                  controller: controller,
-                  isMotionVideo: isMotionVideo,
-                  placeholder: placeholder,
-                  hideControlsTimer: hideControlsTimer,
-                  showControls: showControls,
-                  showDownloadingIndicator: showDownloadingIndicator,
-                  loopVideo: loopVideo,
-                ),
-              ),
-          ],
-        ),
-      ),
-    );
-  }
-}
diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart
index 3f86f5be08..74a94ed6ee 100644
--- a/mobile/lib/pages/photos/memory.page.dart
+++ b/mobile/lib/pages/photos/memory.page.dart
@@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget {
       }
 
       // Precache the asset
+      final size = MediaQuery.sizeOf(context);
       await precacheImage(
         ImmichImage.imageProvider(
           asset: asset,
+          width: size.width,
+          height: size.height,
         ),
         context,
+        size: size,
       );
     }
 
diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart
index 8000c7e339..10fe8de541 100644
--- a/mobile/lib/pages/search/map/map.page.dart
+++ b/mobile/lib/pages/search/map/map.page.dart
@@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
 import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
 import 'package:immich_mobile/models/map/map_event.model.dart';
 import 'package:immich_mobile/models/map/map_marker.model.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 import 'package:immich_mobile/providers/db.provider.dart';
 import 'package:immich_mobile/providers/map/map_marker.provider.dart';
 import 'package:immich_mobile/providers/map/map_state.provider.dart';
@@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget {
 
     useEffect(
       () {
+        final currentAssetLink =
+            ref.read(currentAssetProvider.notifier).ref.keepAlive();
+
         loadMarkers();
-        return null;
+        return currentAssetLink.close;
       },
       [],
     );
@@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget {
         GroupAssetsBy.none,
       );
 
+      ref.read(currentAssetProvider.notifier).set(asset);
+      if (asset.isVideo) {
+        ref.read(showControlsProvider.notifier).show = false;
+      }
       context.pushRoute(
         GalleryViewerRoute(
           initialIndex: 0,
diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart
index c3e4414b39..407aef1610 100644
--- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart
+++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart
@@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
 part 'asset_stack.provider.g.dart';
 
 class AssetStackNotifier extends StateNotifier<List<Asset>> {
-  final Asset _asset;
+  final String _stackId;
   final Ref _ref;
 
-  AssetStackNotifier(
-    this._asset,
-    this._ref,
-  ) : super([]) {
-    fetchStackChildren();
+  AssetStackNotifier(this._stackId, this._ref) : super([]) {
+    _fetchStack(_stackId);
   }
 
-  void fetchStackChildren() async {
-    if (mounted) {
-      state = await _ref.read(assetStackProvider(_asset).future);
+  void _fetchStack(String stackId) async {
+    if (!mounted) {
+      return;
+    }
+
+    final stack = await _ref.read(assetStackProvider(stackId).future);
+    if (stack.isNotEmpty) {
+      state = stack;
     }
   }
 
   void removeChild(int index) {
     if (index < state.length) {
       state.removeAt(index);
+      state = List<Asset>.from(state);
     }
   }
 }
 
 final assetStackStateProvider = StateNotifierProvider.autoDispose
-    .family<AssetStackNotifier, List<Asset>, Asset>(
-  (ref, asset) => AssetStackNotifier(asset, ref),
+    .family<AssetStackNotifier, List<Asset>, String>(
+  (ref, stackId) => AssetStackNotifier(stackId, ref),
 );
 
 final assetStackProvider =
-    FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
-  // Guard [local asset]
-  if (asset.remoteId == null) {
-    return [];
-  }
-
-  return await ref
+    FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
+  return ref
       .watch(dbProvider)
       .assets
       .filter()
       .isArchivedEqualTo(false)
       .isTrashedEqualTo(false)
-      .stackPrimaryAssetIdEqualTo(asset.remoteId)
-      .sortByFileCreatedAtDesc()
+      .stackIdEqualTo(stackId)
+      // orders primary asset first as its ID is null
+      .sortByStackPrimaryAssetId()
+      .thenByFileCreatedAtDesc()
       .findAll();
 });
 
diff --git a/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart
new file mode 100644
index 0000000000..4af061f954
--- /dev/null
+++ b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart
@@ -0,0 +1,23 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+/// Whether to display the video part of a motion photo
+final isPlayingMotionVideoProvider =
+    StateNotifierProvider<IsPlayingMotionVideo, bool>((ref) {
+  return IsPlayingMotionVideo(ref);
+});
+
+class IsPlayingMotionVideo extends StateNotifier<bool> {
+  IsPlayingMotionVideo(this.ref) : super(false);
+
+  final Ref ref;
+
+  bool get playing => state;
+
+  set playing(bool value) {
+    state = value;
+  }
+
+  void toggle() {
+    state = !state;
+  }
+}
diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart
deleted file mode 100644
index 969e181cbb..0000000000
--- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart
+++ /dev/null
@@ -1,46 +0,0 @@
-import 'package:immich_mobile/entities/asset.entity.dart';
-import 'package:immich_mobile/entities/store.entity.dart';
-import 'package:immich_mobile/services/api.service.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/assets/${asset.livePhotoVideoId}/video/playback'
-        : '$serverEndpoint/assets/${asset.remoteId}/video/playback';
-
-    final url = Uri.parse(videoUrl);
-    controller = VideoPlayerController.networkUrl(
-      url,
-      httpHeaders: ApiService.getRequestHeaders(),
-      videoPlayerOptions: asset.livePhotoVideoId != null
-          ? VideoPlayerOptions(mixWithOthers: true)
-          : VideoPlayerOptions(mixWithOthers: false),
-    );
-  }
-
-  await controller.initialize();
-
-  ref.onDispose(() {
-    controller.dispose();
-  });
-
-  return controller;
-}
diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart
deleted file mode 100644
index 00ad37648a..0000000000
--- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart
+++ /dev/null
@@ -1,164 +0,0 @@
-// GENERATED CODE - DO NOT MODIFY BY HAND
-
-part of 'video_player_controller_provider.dart';
-
-// **************************************************************************
-// RiverpodGenerator
-// **************************************************************************
-
-String _$videoPlayerControllerHash() =>
-    r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5';
-
-/// Copied from Dart SDK
-class _SystemHash {
-  _SystemHash._();
-
-  static int combine(int hash, int value) {
-    // ignore: parameter_assignments
-    hash = 0x1fffffff & (hash + value);
-    // ignore: parameter_assignments
-    hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
-    return hash ^ (hash >> 6);
-  }
-
-  static int finish(int hash) {
-    // ignore: parameter_assignments
-    hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
-    // ignore: parameter_assignments
-    hash = hash ^ (hash >> 11);
-    return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
-  }
-}
-
-/// See also [videoPlayerController].
-@ProviderFor(videoPlayerController)
-const videoPlayerControllerProvider = VideoPlayerControllerFamily();
-
-/// See also [videoPlayerController].
-class VideoPlayerControllerFamily
-    extends Family<AsyncValue<VideoPlayerController>> {
-  /// See also [videoPlayerController].
-  const VideoPlayerControllerFamily();
-
-  /// See also [videoPlayerController].
-  VideoPlayerControllerProvider call({
-    required Asset asset,
-  }) {
-    return VideoPlayerControllerProvider(
-      asset: asset,
-    );
-  }
-
-  @override
-  VideoPlayerControllerProvider getProviderOverride(
-    covariant VideoPlayerControllerProvider provider,
-  ) {
-    return call(
-      asset: provider.asset,
-    );
-  }
-
-  static const Iterable<ProviderOrFamily>? _dependencies = null;
-
-  @override
-  Iterable<ProviderOrFamily>? get dependencies => _dependencies;
-
-  static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
-
-  @override
-  Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
-      _allTransitiveDependencies;
-
-  @override
-  String? get name => r'videoPlayerControllerProvider';
-}
-
-/// See also [videoPlayerController].
-class VideoPlayerControllerProvider
-    extends AutoDisposeFutureProvider<VideoPlayerController> {
-  /// See also [videoPlayerController].
-  VideoPlayerControllerProvider({
-    required Asset asset,
-  }) : this._internal(
-          (ref) => videoPlayerController(
-            ref as VideoPlayerControllerRef,
-            asset: asset,
-          ),
-          from: videoPlayerControllerProvider,
-          name: r'videoPlayerControllerProvider',
-          debugGetCreateSourceHash:
-              const bool.fromEnvironment('dart.vm.product')
-                  ? null
-                  : _$videoPlayerControllerHash,
-          dependencies: VideoPlayerControllerFamily._dependencies,
-          allTransitiveDependencies:
-              VideoPlayerControllerFamily._allTransitiveDependencies,
-          asset: asset,
-        );
-
-  VideoPlayerControllerProvider._internal(
-    super._createNotifier, {
-    required super.name,
-    required super.dependencies,
-    required super.allTransitiveDependencies,
-    required super.debugGetCreateSourceHash,
-    required super.from,
-    required this.asset,
-  }) : super.internal();
-
-  final Asset asset;
-
-  @override
-  Override overrideWith(
-    FutureOr<VideoPlayerController> Function(VideoPlayerControllerRef provider)
-        create,
-  ) {
-    return ProviderOverride(
-      origin: this,
-      override: VideoPlayerControllerProvider._internal(
-        (ref) => create(ref as VideoPlayerControllerRef),
-        from: from,
-        name: null,
-        dependencies: null,
-        allTransitiveDependencies: null,
-        debugGetCreateSourceHash: null,
-        asset: asset,
-      ),
-    );
-  }
-
-  @override
-  AutoDisposeFutureProviderElement<VideoPlayerController> createElement() {
-    return _VideoPlayerControllerProviderElement(this);
-  }
-
-  @override
-  bool operator ==(Object other) {
-    return other is VideoPlayerControllerProvider && other.asset == asset;
-  }
-
-  @override
-  int get hashCode {
-    var hash = _SystemHash.combine(0, runtimeType.hashCode);
-    hash = _SystemHash.combine(hash, asset.hashCode);
-
-    return _SystemHash.finish(hash);
-  }
-}
-
-mixin VideoPlayerControllerRef
-    on AutoDisposeFutureProviderRef<VideoPlayerController> {
-  /// The parameter `asset` of this provider.
-  Asset get asset;
-}
-
-class _VideoPlayerControllerProviderElement
-    extends AutoDisposeFutureProviderElement<VideoPlayerController>
-    with VideoPlayerControllerRef {
-  _VideoPlayerControllerProviderElement(super.provider);
-
-  @override
-  Asset get asset => (origin as VideoPlayerControllerProvider).asset;
-}
-// ignore_for_file: type=lint
-// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart
index d15b26ea20..69be91480f 100644
--- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart
+++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart
@@ -1,15 +1,16 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
 
 class VideoPlaybackControls {
-  VideoPlaybackControls({
+  const VideoPlaybackControls({
     required this.position,
-    required this.mute,
     required this.pause,
+    this.restarted = false,
   });
 
   final double position;
-  final bool mute;
   final bool pause;
+  final bool restarted;
 }
 
 final videoPlayerControlsProvider =
@@ -17,15 +18,11 @@ final videoPlayerControlsProvider =
   return VideoPlayerControls(ref);
 });
 
+const videoPlayerControlsDefault =
+    VideoPlaybackControls(position: 0, pause: false);
+
 class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
-  VideoPlayerControls(this.ref)
-      : super(
-          VideoPlaybackControls(
-            position: 0,
-            pause: false,
-            mute: false,
-          ),
-        );
+  VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
 
   final Ref ref;
 
@@ -36,75 +33,48 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
   }
 
   void reset() {
-    state = VideoPlaybackControls(
-      position: 0,
-      pause: false,
-      mute: false,
-    );
+    state = videoPlayerControlsDefault;
   }
 
   double get position => state.position;
-  bool get mute => state.mute;
+  bool get paused => state.pause;
 
   set position(double value) {
-    state = VideoPlaybackControls(
-      position: value,
-      mute: state.mute,
-      pause: state.pause,
-    );
-  }
+    if (state.position == value) {
+      return;
+    }
 
-  set mute(bool value) {
-    state = VideoPlaybackControls(
-      position: state.position,
-      mute: value,
-      pause: state.pause,
-    );
-  }
-
-  void toggleMute() {
-    state = VideoPlaybackControls(
-      position: state.position,
-      mute: !state.mute,
-      pause: state.pause,
-    );
+    state = VideoPlaybackControls(position: value, pause: state.pause);
   }
 
   void pause() {
-    state = VideoPlaybackControls(
-      position: state.position,
-      mute: state.mute,
-      pause: true,
-    );
+    if (state.pause) {
+      return;
+    }
+
+    state = VideoPlaybackControls(position: state.position, pause: true);
   }
 
   void play() {
-    state = VideoPlaybackControls(
-      position: state.position,
-      mute: state.mute,
-      pause: false,
-    );
+    if (!state.pause) {
+      return;
+    }
+
+    state = VideoPlaybackControls(position: state.position, pause: false);
   }
 
   void togglePlay() {
-    state = VideoPlaybackControls(
-      position: state.position,
-      mute: state.mute,
-      pause: !state.pause,
-    );
+    state =
+        VideoPlaybackControls(position: state.position, pause: !state.pause);
   }
 
   void restart() {
-    state = VideoPlaybackControls(
-      position: 0,
-      mute: state.mute,
-      pause: true,
-    );
-
-    state = VideoPlaybackControls(
-      position: 0,
-      mute: state.mute,
-      pause: false,
-    );
+    state =
+        const VideoPlaybackControls(position: 0, pause: false, restarted: true);
+    ref.read(videoPlaybackValueProvider.notifier).value =
+        ref.read(videoPlaybackValueProvider.notifier).value.copyWith(
+              state: VideoPlaybackState.playing,
+              position: Duration.zero,
+            );
   }
 }
diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart
index ebdf739ef0..1a3c54e9e9 100644
--- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart
+++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart
@@ -1,5 +1,5 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:video_player/video_player.dart';
+import 'package:native_video_player/native_video_player.dart';
 
 enum VideoPlaybackState {
   initializing,
@@ -22,56 +22,66 @@ class VideoPlaybackValue {
   /// The volume of the video
   final double volume;
 
-  VideoPlaybackValue({
+  const 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;
+  factory VideoPlaybackValue.fromNativeController(
+    NativeVideoPlayerController controller,
+  ) {
+    final playbackInfo = controller.playbackInfo;
+    final videoInfo = controller.videoInfo;
+
+    if (playbackInfo == null || videoInfo == null) {
+      return videoPlaybackValueDefault;
     }
 
+    final VideoPlaybackState status = switch (playbackInfo.status) {
+      PlaybackStatus.playing => VideoPlaybackState.playing,
+      PlaybackStatus.paused => VideoPlaybackState.paused,
+      PlaybackStatus.stopped => VideoPlaybackState.completed,
+    };
+
     return VideoPlaybackValue(
-      position: video?.position ?? Duration.zero,
-      duration: video?.duration ?? Duration.zero,
-      state: s,
-      volume: video?.volume ?? 0.0,
+      position: Duration(seconds: playbackInfo.position),
+      duration: Duration(seconds: videoInfo.duration),
+      state: status,
+      volume: playbackInfo.volume,
     );
   }
 
-  factory VideoPlaybackValue.uninitialized() {
+  VideoPlaybackValue copyWith({
+    Duration? position,
+    Duration? duration,
+    VideoPlaybackState? state,
+    double? volume,
+  }) {
     return VideoPlaybackValue(
-      position: Duration.zero,
-      duration: Duration.zero,
-      state: VideoPlaybackState.initializing,
-      volume: 0.0,
+      position: position ?? this.position,
+      duration: duration ?? this.duration,
+      state: state ?? this.state,
+      volume: volume ?? this.volume,
     );
   }
 }
 
+const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
+  position: Duration.zero,
+  duration: Duration.zero,
+  state: VideoPlaybackState.initializing,
+  volume: 0.0,
+);
+
 final videoPlaybackValueProvider =
     StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
   return VideoPlaybackValueState(ref);
 });
 
 class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
-  VideoPlaybackValueState(this.ref)
-      : super(
-          VideoPlaybackValue.uninitialized(),
-        );
+  VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
 
   final Ref ref;
 
@@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
   }
 
   set position(Duration value) {
+    if (state.position == value) return;
     state = VideoPlaybackValue(
       position: value,
       duration: state.duration,
@@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
       volume: state.volume,
     );
   }
+
+  set status(VideoPlaybackState value) {
+    if (state.state == value) return;
+    state = VideoPlaybackValue(
+      position: state.position,
+      duration: state.duration,
+      state: value,
+      volume: state.volume,
+    );
+  }
+
+  void reset() {
+    state = videoPlaybackValueDefault;
+  }
 }
diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart
index bbfaf12a4f..36fd3334b9 100644
--- a/mobile/lib/providers/image/immich_local_image_provider.dart
+++ b/mobile/lib/providers/image/immich_local_image_provider.dart
@@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/painting.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:logging/logging.dart';
 import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
 
 /// The local image provider for an asset
 class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
   final Asset asset;
+  // only used for videos
+  final double width;
+  final double height;
+  final Logger log = Logger('ImmichLocalImageProvider');
 
   ImmichLocalImageProvider({
     required this.asset,
+    required this.width,
+    required this.height,
   }) : assert(asset.local != null, 'Only usable when asset.local is set');
 
   /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@@ -42,38 +49,57 @@ class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
 
   // Streams in each stage of the image as we ask for it
   Stream<ui.Codec> _codec(
-    Asset key,
+    Asset asset,
     ImageDecoderCallback decode,
     StreamController<ImageChunkEvent> chunkEvents,
   ) async* {
-    // Load a small thumbnail
-    final thumbBytes = await asset.local?.thumbnailDataWithSize(
-      const ThumbnailSize.square(256),
-      quality: 80,
-    );
-    if (thumbBytes != null) {
-      final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
-      final codec = await decode(buffer);
-      yield codec;
-    } else {
-      debugPrint("Loading thumb for ${asset.fileName} failed");
-    }
-
-    if (asset.isImage) {
-      final File? file = await asset.local?.originFile;
-      if (file == null) {
-        throw StateError("Opening file for asset ${asset.fileName} failed");
+    ui.ImmutableBuffer? buffer;
+    try {
+      final local = asset.local;
+      if (local == null) {
+        throw StateError('Asset ${asset.fileName} has no local data');
       }
-      try {
-        final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
-        final codec = await decode(buffer);
-        yield codec;
-      } catch (error) {
-        throw StateError("Loading asset ${asset.fileName} failed");
-      }
-    }
 
-    chunkEvents.close();
+      var thumbBytes = await local
+          .thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80);
+      if (thumbBytes == null) {
+        throw StateError("Loading thumbnail for ${asset.fileName} failed");
+      }
+      buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
+      thumbBytes = null;
+      yield await decode(buffer);
+      buffer = null;
+
+      switch (asset.type) {
+        case AssetType.image:
+          final File? file = await local.originFile;
+          if (file == null) {
+            throw StateError("Opening file for asset ${asset.fileName} failed");
+          }
+          buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
+          yield await decode(buffer);
+          buffer = null;
+          break;
+        case AssetType.video:
+          final size = ThumbnailSize(width.ceil(), height.ceil());
+          thumbBytes = await local.thumbnailDataWithSize(size);
+          if (thumbBytes == null) {
+            throw StateError("Failed to load preview for ${asset.fileName}");
+          }
+          buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
+          thumbBytes = null;
+          yield await decode(buffer);
+          buffer = null;
+          break;
+        default:
+          throw StateError('Unsupported asset type ${asset.type}');
+      }
+    } catch (error, stack) {
+      log.severe('Error loading local image ${asset.fileName}', error, stack);
+      buffer?.dispose();
+    } finally {
+      chunkEvents.close();
+    }
   }
 
   @override
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index b001c6bdd6..785d23a7ad 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
 import 'package:immich_mobile/pages/backup/backup_options.page.dart';
 import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
 import 'package:immich_mobile/pages/albums/albums.page.dart';
+import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
 import 'package:immich_mobile/pages/library/local_albums.page.dart';
 import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
 import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
@@ -272,6 +273,10 @@ class AppRouter extends RootStackRouter {
       guards: [_authGuard, _duplicateGuard],
       transitionsBuilder: TransitionsBuilders.slideLeft,
     ),
+    AutoRoute(
+      page: NativeVideoViewerRoute.page,
+      guards: [_authGuard, _duplicateGuard],
+    ),
   ];
 }
 
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index ea7d385e85..48ee4db5fd 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -1079,6 +1079,64 @@ class MemoryRouteArgs {
   }
 }
 
+/// generated route for
+/// [NativeVideoViewerPage]
+class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
+  NativeVideoViewerRoute({
+    Key? key,
+    required Asset asset,
+    required Widget image,
+    bool showControls = true,
+    List<PageRouteInfo>? children,
+  }) : super(
+          NativeVideoViewerRoute.name,
+          args: NativeVideoViewerRouteArgs(
+            key: key,
+            asset: asset,
+            image: image,
+            showControls: showControls,
+          ),
+          initialChildren: children,
+        );
+
+  static const String name = 'NativeVideoViewerRoute';
+
+  static PageInfo page = PageInfo(
+    name,
+    builder: (data) {
+      final args = data.argsAs<NativeVideoViewerRouteArgs>();
+      return NativeVideoViewerPage(
+        key: args.key,
+        asset: args.asset,
+        image: args.image,
+        showControls: args.showControls,
+      );
+    },
+  );
+}
+
+class NativeVideoViewerRouteArgs {
+  const NativeVideoViewerRouteArgs({
+    this.key,
+    required this.asset,
+    required this.image,
+    this.showControls = true,
+  });
+
+  final Key? key;
+
+  final Asset asset;
+
+  final Widget image;
+
+  final bool showControls;
+
+  @override
+  String toString() {
+    return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}';
+  }
+}
+
 /// generated route for
 /// [PartnerDetailPage]
 class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart
index b2cad4dc82..7d27d1b27b 100644
--- a/mobile/lib/services/asset.service.dart
+++ b/mobile/lib/services/asset.service.dart
@@ -1,4 +1,5 @@
 import 'dart:async';
+import 'dart:io';
 
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
@@ -402,4 +403,29 @@ class AssetService {
 
     return exifInfo?.description ?? "";
   }
+
+  Future<double> getAspectRatio(Asset asset) async {
+    // platform_manager always returns 0 for orientation on iOS, so only prefer it on Android
+    if (asset.isLocal && Platform.isAndroid) {
+      await asset.localAsync;
+    } else if (asset.isRemote) {
+      asset = await loadExif(asset);
+    } else if (asset.isLocal) {
+      await asset.localAsync;
+    }
+
+    final aspectRatio = asset.aspectRatio;
+    if (aspectRatio != null) {
+      return aspectRatio;
+    }
+
+    final width = asset.width;
+    final height = asset.height;
+    if (width != null && height != null) {
+      // we don't know the orientation, so assume it's normal
+      return width / height;
+    }
+
+    return 1.0;
+  }
 }
diff --git a/mobile/lib/utils/debounce.dart b/mobile/lib/utils/debounce.dart
index ca5f8fc2be..78870151a6 100644
--- a/mobile/lib/utils/debounce.dart
+++ b/mobile/lib/utils/debounce.dart
@@ -3,20 +3,52 @@ import 'dart:async';
 import 'package:flutter_hooks/flutter_hooks.dart';
 
 /// Used to debounce function calls with the [interval] provided.
+/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied.
 class Debouncer {
-  Debouncer({required this.interval});
+  Debouncer({required this.interval, this.maxWaitTime});
   final Duration interval;
+  final Duration? maxWaitTime;
   Timer? _timer;
   FutureOr<void> Function()? _lastAction;
+  DateTime? _lastActionTime;
+  Future<void>? _actionFuture;
 
   void run(FutureOr<void> Function() action) {
     _lastAction = action;
     _timer?.cancel();
+
+    if (maxWaitTime != null &&
+        // _actionFuture == null && // TODO: should this check be here?
+        (_lastActionTime == null ||
+            DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) {
+      _callAndRest();
+      return;
+    }
     _timer = Timer(interval, _callAndRest);
   }
 
+  Future<void>? drain() {
+    if (_timer != null && _timer!.isActive) {
+      _timer!.cancel();
+      if (_lastAction != null) {
+        _callAndRest();
+      }
+    }
+    return _actionFuture;
+  }
+
+  @pragma('vm:prefer-inline')
   void _callAndRest() {
-    _lastAction?.call();
+    _lastActionTime = DateTime.now();
+    final action = _lastAction;
+    _lastAction = null;
+
+    final result = action!();
+    if (result is Future) {
+      _actionFuture = result.whenComplete(() {
+        _actionFuture = null;
+      });
+    }
     _timer = null;
   }
 
@@ -24,31 +56,48 @@ class Debouncer {
     _timer?.cancel();
     _timer = null;
     _lastAction = null;
+    _lastActionTime = null;
+    _actionFuture = null;
   }
+
+  bool get isActive =>
+      _actionFuture != null || (_timer != null && _timer!.isActive);
 }
 
 /// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
 /// default interval of 300ms is used to debounce the function calls
 Debouncer useDebouncer({
   Duration interval = const Duration(milliseconds: 300),
+  Duration? maxWaitTime,
   List<Object?>? keys,
 }) =>
-    use(_DebouncerHook(interval: interval, keys: keys));
+    use(
+      _DebouncerHook(
+        interval: interval,
+        maxWaitTime: maxWaitTime,
+        keys: keys,
+      ),
+    );
 
 class _DebouncerHook extends Hook<Debouncer> {
   const _DebouncerHook({
     required this.interval,
+    this.maxWaitTime,
     super.keys,
   });
 
   final Duration interval;
+  final Duration? maxWaitTime;
 
   @override
   HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
 }
 
 class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
-  late final debouncer = Debouncer(interval: hook.interval);
+  late final debouncer = Debouncer(
+    interval: hook.interval,
+    maxWaitTime: hook.maxWaitTime,
+  );
 
   @override
   Debouncer build(_) => debouncer;
diff --git a/mobile/lib/utils/hooks/chewiew_controller_hook.dart b/mobile/lib/utils/hooks/chewiew_controller_hook.dart
deleted file mode 100644
index 2868e896cf..0000000000
--- a/mobile/lib/utils/hooks/chewiew_controller_hook.dart
+++ /dev/null
@@ -1,161 +0,0 @@
-import 'package:chewie/chewie.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:video_player/video_player.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({
-  required VideoPlayerController controller,
-  EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
-    bottom: 100,
-  ),
-  bool showOptions = true,
-  bool showControlsOnInitialize = false,
-  bool autoPlay = true,
-  bool allowFullScreen = false,
-  bool allowedScreenSleep = false,
-  bool showControls = true,
-  bool loopVideo = false,
-  Widget? customControls,
-  Widget? placeholder,
-  Duration hideControlsTimer = const Duration(seconds: 1),
-  VoidCallback? onPlaying,
-  VoidCallback? onPaused,
-  VoidCallback? onVideoEnded,
-}) {
-  return use(
-    _ChewieControllerHook(
-      controller: controller,
-      placeholder: placeholder,
-      showOptions: showOptions,
-      controlsSafeAreaMinimum: controlsSafeAreaMinimum,
-      autoPlay: autoPlay,
-      allowFullScreen: allowFullScreen,
-      customControls: customControls,
-      hideControlsTimer: hideControlsTimer,
-      showControlsOnInitialize: showControlsOnInitialize,
-      showControls: showControls,
-      loopVideo: loopVideo,
-      allowedScreenSleep: allowedScreenSleep,
-      onPlaying: onPlaying,
-      onPaused: onPaused,
-      onVideoEnded: onVideoEnded,
-    ),
-  );
-}
-
-class _ChewieControllerHook extends Hook<ChewieController> {
-  final VideoPlayerController controller;
-  final EdgeInsets controlsSafeAreaMinimum;
-  final bool showOptions;
-  final bool showControlsOnInitialize;
-  final bool autoPlay;
-  final bool allowFullScreen;
-  final bool allowedScreenSleep;
-  final bool showControls;
-  final bool loopVideo;
-  final Widget? customControls;
-  final Widget? placeholder;
-  final Duration hideControlsTimer;
-  final VoidCallback? onPlaying;
-  final VoidCallback? onPaused;
-  final VoidCallback? onVideoEnded;
-
-  const _ChewieControllerHook({
-    required this.controller,
-    this.controlsSafeAreaMinimum = const EdgeInsets.only(
-      bottom: 100,
-    ),
-    this.showOptions = true,
-    this.showControlsOnInitialize = false,
-    this.autoPlay = true,
-    this.allowFullScreen = false,
-    this.allowedScreenSleep = false,
-    this.showControls = true,
-    this.loopVideo = false,
-    this.customControls,
-    this.placeholder,
-    this.hideControlsTimer = const Duration(seconds: 3),
-    this.onPlaying,
-    this.onPaused,
-    this.onVideoEnded,
-  });
-
-  @override
-  createState() => _ChewieControllerHookState();
-}
-
-class _ChewieControllerHookState
-    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,
-    looping: hook.loopVideo,
-    customControls: hook.customControls,
-    placeholder: hook.placeholder,
-    hideControlsTimer: hook.hideControlsTimer,
-  );
-
-  @override
-  void dispose() {
-    chewieController.dispose();
-    super.dispose();
-  }
-
-  @override
-  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) {
-      // Use a local file for the video player controller
-      final file = await hook.asset.local!.file;
-      if (file == null) {
-        throw Exception('No file found for the video');
-      }
-      videoPlayerController = VideoPlayerController.file(file);
-    } else {
-      // Use a network URL for the video player controller
-      final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
-      final String videoUrl = hook.asset.livePhotoVideoId != null
-          ? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback'
-          : '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback';
-
-      final url = Uri.parse(videoUrl);
-      final accessToken = store.Store.get(StoreKey.accessToken);
-
-      videoPlayerController = VideoPlayerController.networkUrl(
-        url,
-        httpHeaders: {"x-immich-user-token": accessToken},
-      );
-    }
-
-    await videoPlayerController!.initialize();
-
-    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/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart
new file mode 100644
index 0000000000..0c346065f7
--- /dev/null
+++ b/mobile/lib/utils/hooks/interval_hook.dart
@@ -0,0 +1,18 @@
+import 'dart:async';
+import 'dart:ui';
+
+import 'package:flutter_hooks/flutter_hooks.dart';
+
+// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
+void useInterval(Duration delay, VoidCallback callback) {
+  final savedCallback = useRef(callback);
+  savedCallback.value = callback;
+
+  useEffect(
+    () {
+      final timer = Timer.periodic(delay, (_) => savedCallback.value());
+      return timer.cancel;
+    },
+    [delay],
+  );
+}
diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart
index 2b02a5ff8f..67ff060075 100644
--- a/mobile/lib/utils/migration.dart
+++ b/mobile/lib/utils/migration.dart
@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
 import 'package:immich_mobile/utils/db.dart';
 import 'package:isar/isar.dart';
 
-const int targetVersion = 6;
+const int targetVersion = 7;
 
 Future<void> migrateDatabaseIfNeeded(Isar db) async {
   final int version = Store.get(StoreKey.version, 1);
diff --git a/mobile/lib/utils/throttle.dart b/mobile/lib/utils/throttle.dart
index 9a54e01fc1..bc0dcf9e2f 100644
--- a/mobile/lib/utils/throttle.dart
+++ b/mobile/lib/utils/throttle.dart
@@ -1,5 +1,3 @@
-import 'dart:async';
-
 import 'package:flutter_hooks/flutter_hooks.dart';
 
 /// Throttles function calls with the [interval] provided.
@@ -10,12 +8,15 @@ class Throttler {
 
   Throttler({required this.interval});
 
-  void run(FutureOr<void> Function() action) {
+  T? run<T>(T Function() action) {
     if (_lastActionTime == null ||
         (DateTime.now().difference(_lastActionTime!) > interval)) {
-      action();
+      final response = action();
       _lastActionTime = DateTime.now();
+      return response;
     }
+
+    return null;
   }
 
   void dispose() {
diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
index 38e499b5de..5670aa388f 100644
--- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
+++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
@@ -12,7 +12,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/extensions/collection_extensions.dart';
 import 'package:immich_mobile/extensions/theme_extensions.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
 import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
 import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
@@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
       ScrollOffsetController();
   final ItemPositionsListener _itemPositionsListener =
       ItemPositionsListener.create();
+  late final KeepAliveLink currentAssetLink;
 
   /// The timestamp when the haptic feedback was last invoked
   int _hapticFeedbackTS = 0;
@@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
       allAssetsSelected: _allAssetsSelected,
       showStack: widget.showStack,
       heroOffset: widget.heroOffset,
+      onAssetTap: (asset) {
+        ref.read(currentAssetProvider.notifier).set(asset);
+        if (asset.isVideo) {
+          ref.read(showControlsProvider.notifier).show = false;
+        }
+      },
     );
   }
 
@@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
   @override
   void initState() {
     super.initState();
+    currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
     scrollToTopNotifierProvider.addListener(_scrollToTop);
     scrollToDateNotifierProvider.addListener(_scrollToDate);
 
@@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
       _itemPositionsListener.itemPositions.removeListener(_positionListener);
     }
     _itemPositionsListener.itemPositions.removeListener(_hapticsListener);
+    currentAssetLink.close();
     super.dispose();
   }
 
@@ -595,12 +606,13 @@ class _Section extends StatelessWidget {
   final RenderList renderList;
   final bool selectionActive;
   final bool dynamicLayout;
-  final Function(List<Asset>) selectAssets;
-  final Function(List<Asset>) deselectAssets;
+  final void Function(List<Asset>) selectAssets;
+  final void Function(List<Asset>) deselectAssets;
   final bool Function(List<Asset>) allAssetsSelected;
   final bool showStack;
   final int heroOffset;
   final bool showStorageIndicator;
+  final void Function(Asset) onAssetTap;
 
   const _Section({
     required this.section,
@@ -618,6 +630,7 @@ class _Section extends StatelessWidget {
     required this.showStack,
     required this.heroOffset,
     required this.showStorageIndicator,
+    required this.onAssetTap,
   });
 
   @override
@@ -683,6 +696,7 @@ class _Section extends StatelessWidget {
                       selectionActive: selectionActive,
                       onSelect: (asset) => selectAssets([asset]),
                       onDeselect: (asset) => deselectAssets([asset]),
+                      onAssetTap: onAssetTap,
                     ),
           ],
         );
@@ -724,9 +738,9 @@ class _Title extends StatelessWidget {
   final String title;
   final List<Asset> assets;
   final bool selectionActive;
-  final Function(List<Asset>) selectAssets;
-  final Function(List<Asset>) deselectAssets;
-  final Function(List<Asset>) allAssetsSelected;
+  final void Function(List<Asset>) selectAssets;
+  final void Function(List<Asset>) deselectAssets;
+  final bool Function(List<Asset>) allAssetsSelected;
 
   const _Title({
     required this.title,
@@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget {
   final bool showStorageIndicator;
   final int heroOffset;
   final bool showStack;
-  final Function(Asset)? onSelect;
-  final Function(Asset)? onDeselect;
+  final void Function(Asset) onAssetTap;
+  final void Function(Asset)? onSelect;
+  final void Function(Asset)? onDeselect;
   final bool isSelectionActive;
 
   const _AssetRow({
@@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget {
     required this.showStack,
     required this.isSelectionActive,
     required this.selectedAssets,
+    required this.onAssetTap,
     this.onSelect,
     this.onDeselect,
   });
@@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget {
                   onSelect?.call(asset);
                 }
               } else {
+                final asset = renderList.loadAsset(absoluteOffset + index);
+                onAssetTap(asset);
                 context.pushRoute(
                   GalleryViewerRoute(
                     renderList: renderList,
diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
index 82ca295d8a..256141dc7d 100644
--- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
@@ -5,11 +5,11 @@ 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/constants/immich_colors.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/providers/album/album.provider.dart';
 import 'package:immich_mobile/providers/album/current_album.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 import 'package:immich_mobile/services/stack.service.dart';
@@ -26,12 +26,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
 import 'package:immich_mobile/pages/editing/edit.page.dart';
 
 class BottomGalleryBar extends ConsumerWidget {
-  final Asset asset;
   final ValueNotifier<int> assetIndex;
   final bool showStack;
-  final int stackIndex;
+  final ValueNotifier<int> stackIndex;
   final ValueNotifier<int> totalAssets;
-  final bool showVideoPlayerControls;
   final PageController controller;
   final RenderList renderList;
 
@@ -39,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget {
     super.key,
     required this.showStack,
     required this.stackIndex,
-    required this.asset,
     required this.assetIndex,
     required this.controller,
     required this.totalAssets,
-    required this.showVideoPlayerControls,
     required this.renderList,
   });
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final asset = ref.watch(currentAssetProvider);
+    if (asset == null) {
+      return const SizedBox();
+    }
     final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
+    final showControls = ref.watch(showControlsProvider);
+    final stackId = asset.stackId;
 
-    final stackItems = showStack && asset.stackCount > 0
-        ? ref.watch(assetStackStateProvider(asset))
+    final stackItems = showStack && stackId != null
+        ? ref.watch(assetStackStateProvider(stackId))
         : <Asset>[];
     bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
     final navStack = AutoRouter.of(context).stackData;
@@ -64,10 +66,10 @@ class BottomGalleryBar extends ConsumerWidget {
     final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
 
     void removeAssetFromStack() {
-      if (stackIndex > 0 && showStack) {
+      if (stackIndex.value > 0 && showStack && stackId != null) {
         ref
-            .read(assetStackStateProvider(asset).notifier)
-            .removeChild(stackIndex - 1);
+            .read(assetStackStateProvider(stackId).notifier)
+            .removeChild(stackIndex.value - 1);
       }
     }
 
@@ -135,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
 
       await ref
           .read(stackServiceProvider)
-          .deleteStack(asset.stackId!, [asset, ...stackItems]);
+          .deleteStack(asset.stackId!, stackItems);
     }
 
     void showStackActionItems() {
@@ -324,16 +326,16 @@ class BottomGalleryBar extends ConsumerWidget {
         },
     ];
     return IgnorePointer(
-      ignoring: !ref.watch(showControlsProvider),
+      ignoring: !showControls,
       child: AnimatedOpacity(
         duration: const Duration(milliseconds: 100),
-        opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
+        opacity: showControls ? 1.0 : 0.0,
         child: DecoratedBox(
           decoration: const BoxDecoration(
             gradient: LinearGradient(
               begin: Alignment.bottomCenter,
               end: Alignment.topCenter,
-              colors: [blackOpacity90, Colors.transparent],
+              colors: [Colors.black, Colors.transparent],
             ),
           ),
           position: DecorationPosition.background,
@@ -341,7 +343,7 @@ class BottomGalleryBar extends ConsumerWidget {
             padding: const EdgeInsets.only(top: 40.0),
             child: Column(
               children: [
-                if (showVideoPlayerControls) const VideoControls(),
+                if (asset.isVideo) const VideoControls(),
                 BottomNavigationBar(
                   elevation: 0.0,
                   backgroundColor: Colors.transparent,
diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart
index a34fcb9baf..d759b0d80b 100644
--- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart
+++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart
@@ -1,38 +1,48 @@
 import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
+import 'package:immich_mobile/utils/hooks/timer_hook.dart';
 import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
 import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
-import 'package:immich_mobile/utils/hooks/timer_hook.dart';
 
 class CustomVideoPlayerControls extends HookConsumerWidget {
   final Duration hideTimerDuration;
 
   const CustomVideoPlayerControls({
     super.key,
-    this.hideTimerDuration = const Duration(seconds: 3),
+    this.hideTimerDuration = const Duration(seconds: 5),
   });
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final assetIsVideo = ref.watch(
+      currentAssetProvider.select((asset) => asset != null && asset.isVideo),
+    );
+    final showControls = ref.watch(showControlsProvider);
+    final VideoPlaybackState state =
+        ref.watch(videoPlaybackValueProvider.select((value) => value.state));
+
     // A timer to hide the controls
     final hideTimer = useTimer(
       hideTimerDuration,
       () {
+        if (!context.mounted) {
+          return;
+        }
         final state = ref.read(videoPlaybackValueProvider).state;
+
         // Do not hide on paused
-        if (state != VideoPlaybackState.paused) {
+        if (state != VideoPlaybackState.paused &&
+            state != VideoPlaybackState.completed &&
+            assetIsVideo) {
           ref.read(showControlsProvider.notifier).show = false;
         }
       },
     );
-
-    final showBuffering = useState(false);
-    final VideoPlaybackState state =
-        ref.watch(videoPlaybackValueProvider).state;
+    final showBuffering = state == VideoPlaybackState.buffering;
 
     /// Shows the controls and starts the timer to hide them
     void showControlsAndStartHideTimer() {
@@ -40,28 +50,15 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
       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 if (state == VideoPlaybackState.completed) {
@@ -75,10 +72,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
       behavior: HitTestBehavior.opaque,
       onTap: showControlsAndStartHideTimer,
       child: AbsorbPointer(
-        absorbing: !ref.watch(showControlsProvider),
+        absorbing: !showControls,
         child: Stack(
           children: [
-            if (showBuffering.value)
+            if (showBuffering)
               const Center(
                 child: DelayedLoadingIndicator(
                   fadeInDuration: Duration(milliseconds: 400),
@@ -86,18 +83,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
               )
             else
               GestureDetector(
-                onTap: () {
-                  if (state != VideoPlaybackState.playing) {
-                    togglePlay();
-                  }
-                  ref.read(showControlsProvider.notifier).show = false;
-                },
+                onTap: () =>
+                    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),
+                  show: assetIsVideo && showControls,
                   onPressed: togglePlay,
                 ),
               ),
diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart
index 3c650bdc6a..0dd3305302 100644
--- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart
+++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart
@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
   Widget build(BuildContext context) {
     final textColor = context.isDarkTheme ? Colors.white : Colors.black;
 
-    String resolution = asset.width != null && asset.height != null
-        ? "${asset.height} x ${asset.width}  "
-        : "";
+    final height = asset.orientatedHeight ?? asset.height;
+    final width = asset.orientatedWidth ?? asset.width;
+    String resolution =
+        height != null && width != null ? "$height x $width  " : "";
     String fileSize = asset.exifInfo?.fileSize != null
         ? formatBytes(asset.exifInfo!.fileSize!)
         : "";
diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
index f400224e0a..f7e2158ea9 100644
--- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/providers/album/current_album.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
 import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
 import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
@@ -19,23 +20,19 @@ import 'package:immich_mobile/providers/user.provider.dart';
 import 'package:immich_mobile/widgets/common/immich_toast.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,
-  });
+  const GalleryAppBar({super.key, required this.showInfo});
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final asset = ref.watch(currentAssetProvider);
+    if (asset == null) {
+      return const SizedBox();
+    }
     final album = ref.watch(currentAlbumProvider);
     final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
+    final showControls = ref.watch(showControlsProvider);
 
     final isPartner = ref
         .watch(partnerSharedWithProvider)
@@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget {
     }
 
     return IgnorePointer(
-      ignoring: !ref.watch(showControlsProvider),
+      ignoring: !showControls,
       child: AnimatedOpacity(
         duration: const Duration(milliseconds: 100),
-        opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
+        opacity: showControls ? 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,
             onRestorePressed: () => handleRestore(asset),
             onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
             onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
-            onToggleMotionVideo: onToggleMotionVideo,
             onAddToAlbumPressed: () => addToAlbum(asset),
             onActivitiesPressed: handleActivities,
           ),
diff --git a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart
new file mode 100644
index 0000000000..e4dd355554
--- /dev/null
+++ b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
+
+class MotionPhotoButton extends ConsumerWidget {
+  const MotionPhotoButton({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final isPlaying = ref.watch(isPlayingMotionVideoProvider);
+
+    return IconButton(
+      onPressed: () {
+        ref.read(isPlayingMotionVideoProvider.notifier).toggle();
+      },
+      icon: isPlaying
+          ? const Icon(Icons.motion_photos_pause_outlined, color: grey200)
+          : const Icon(Icons.play_circle_outline_rounded, color: grey200),
+    );
+  }
+}
diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
index 984b61f50c..2bdbb72ec0 100644
--- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
@@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart';
 import 'package:immich_mobile/providers/album/current_album.provider.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
 import 'package:immich_mobile/providers/asset.provider.dart';
+import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
 
 class TopControlAppBar extends HookConsumerWidget {
   const TopControlAppBar({
@@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
     required this.onDownloadPressed,
     required this.onAddToAlbumPressed,
     required this.onRestorePressed,
-    required this.onToggleMotionVideo,
-    required this.isPlayingMotionVideo,
     required this.onFavorite,
     required this.onUploadPressed,
     required this.isOwner,
@@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget {
   final Function onMoreInfoPressed;
   final VoidCallback? onUploadPressed;
   final VoidCallback? onDownloadPressed;
-  final VoidCallback onToggleMotionVideo;
   final VoidCallback onAddToAlbumPressed;
   final VoidCallback onRestorePressed;
   final VoidCallback onActivitiesPressed;
   final Function(Asset) onFavorite;
-  final bool isPlayingMotionVideo;
   final bool isOwner;
   final bool isPartner;
 
@@ -57,23 +54,6 @@ class TopControlAppBar extends HookConsumerWidget {
       );
     }
 
-    Widget buildLivePhotoButton() {
-      return IconButton(
-        onPressed: () {
-          onToggleMotionVideo();
-        },
-        icon: isPlayingMotionVideo
-            ? Icon(
-                Icons.motion_photos_pause_outlined,
-                color: Colors.grey[200],
-              )
-            : Icon(
-                Icons.play_circle_outline_rounded,
-                color: Colors.grey[200],
-              ),
-      );
-    }
-
     Widget buildMoreInfoButton() {
       return IconButton(
         onPressed: () {
@@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget {
       foregroundColor: Colors.grey[100],
       backgroundColor: Colors.transparent,
       leading: buildBackButton(),
-      actionsIconTheme: const IconThemeData(
-        size: iconSize,
-      ),
+      actionsIconTheme: const IconThemeData(size: iconSize),
       shape: const Border(),
       actions: [
         if (asset.isRemote && isOwner) buildFavoriteButton(a),
-        if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
+        if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
         if (asset.isLocal && !asset.isRemote) buildUploadButton(),
         if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
         if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart
deleted file mode 100644
index ebf158b59a..0000000000
--- a/mobile/lib/widgets/asset_viewer/video_player.dart
+++ /dev/null
@@ -1,48 +0,0 @@
-import 'package:chewie/chewie.dart';
-import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart';
-import 'package:immich_mobile/widgets/asset_viewer/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;
-  final bool loopVideo;
-
-  const VideoPlayerViewer({
-    super.key,
-    required this.controller,
-    required this.isMotionVideo,
-    this.placeholder,
-    required this.hideControlsTimer,
-    required this.showControls,
-    required this.showDownloadingIndicator,
-    required this.loopVideo,
-  });
-
-  @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,
-      loopVideo: loopVideo,
-    );
-
-    return Chewie(
-      controller: chewie,
-    );
-  }
-}
diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart
index ef309b9c85..b1f70b8686 100644
--- a/mobile/lib/widgets/asset_viewer/video_position.dart
+++ b/mobile/lib/widgets/asset_viewer/video_position.dart
@@ -56,10 +56,16 @@ class VideoPosition extends HookConsumerWidget {
                           ref.read(videoPlayerControlsProvider.notifier).play();
                         }
                       },
-                      onChanged: (position) {
+                      onChanged: (value) {
+                        final inSeconds =
+                            (duration * (value / 100.0)).inSeconds;
+                        final position = inSeconds.toDouble();
                         ref
                             .read(videoPlayerControlsProvider.notifier)
                             .position = position;
+                        // This immediately updates the slider position without waiting for the video to update
+                        ref.read(videoPlaybackValueProvider.notifier).position =
+                            Duration(seconds: inSeconds);
                       },
                     ),
                   ),
diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart
index 5946dee453..ab0f2584b5 100644
--- a/mobile/lib/widgets/common/immich_image.dart
+++ b/mobile/lib/widgets/common/immich_image.dart
@@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget {
   // either by using the asset ID or the asset itself
   /// [asset] is the Asset to request, or else use [assetId] to get a remote
   /// image provider
-  /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
-  /// The size of the square thumbnail to request. Ignored if isThumbnail
-  /// is not true
   static ImageProvider imageProvider({
     Asset? asset,
     String? assetId,
+    double width = 1080,
+    double height = 1920,
   }) {
     if (asset == null && assetId == null) {
       throw Exception('Must supply either asset or assetId');
@@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget {
     if (useLocal(asset)) {
       return ImmichLocalImageProvider(
         asset: asset,
+        width: width,
+        height: height,
       );
     } else {
       return ImmichRemoteImageProvider(
@@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget {
       },
       image: ImmichImage.imageProvider(
         asset: asset,
+        width: context.width,
+        height: context.height,
       ),
       width: width,
       height: height,
diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart
index fb7cc882a0..4954d0bfcc 100644
--- a/mobile/lib/widgets/memories/memory_card.dart
+++ b/mobile/lib/widgets/memories/memory_card.dart
@@ -2,9 +2,9 @@ import 'dart:ui';
 
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
-import 'package:immich_mobile/pages/common/video_viewer.page.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
 import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
 import 'package:immich_mobile/widgets/common/immich_image.dart';
 
@@ -68,18 +68,20 @@ class MemoryCard extends StatelessWidget {
               } else {
                 return Hero(
                   tag: 'memory-${asset.id}',
-                  child: VideoViewerPage(
-                    key: ValueKey(asset),
-                    asset: asset,
-                    showDownloadingIndicator: false,
-                    placeholder: SizedBox.expand(
-                      child: ImmichImage(
+                  child: SizedBox(
+                    width: context.width,
+                    height: context.height,
+                    child: NativeVideoViewerPage(
+                      key: ValueKey(asset.id),
+                      asset: asset,
+                      showControls: false,
+                      image: ImmichImage(
                         asset,
+                        width: context.width,
+                        height: context.height,
                         fit: fit,
                       ),
                     ),
-                    hideControlsTimer: const Duration(seconds: 2),
-                    showControls: false,
                   ),
                 );
               }
@@ -137,6 +139,8 @@ class _BlurredBackdrop extends HookWidget {
             image: DecorationImage(
               image: ImmichImage.imageProvider(
                 asset: asset,
+                height: context.height,
+                width: context.width,
               ),
               fit: BoxFit.cover,
             ),
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 9dc53e42b9..9203dcdf82 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -214,14 +214,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.0.3"
-  chewie:
-    dependency: "direct main"
-    description:
-      name: chewie
-      sha256: "2243e41e79e865d426d9dd9c1a9624aa33c4ad11de2d0cd680f826e2cd30e879"
-      url: "https://pub.dev"
-    source: hosted
-    version: "1.8.3"
   ci:
     dependency: transitive
     description:
@@ -318,14 +310,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.0"
-  cupertino_icons:
-    dependency: transitive
-    description:
-      name: cupertino_icons
-      sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
-      url: "https://pub.dev"
-    source: hosted
-    version: "1.0.8"
   custom_lint:
     dependency: "direct dev"
     description:
@@ -378,10 +362,10 @@ packages:
     dependency: "direct main"
     description:
       name: device_info_plus
-      sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
+      sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
       url: "https://pub.dev"
     source: hosted
-    version: "11.0.0"
+    version: "11.1.1"
   device_info_plus_platform_interface:
     dependency: transitive
     description:
@@ -450,10 +434,10 @@ packages:
     dependency: "direct main"
     description:
       name: file_picker
-      sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12"
+      sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877
       url: "https://pub.dev"
     source: hosted
-    version: "8.1.2"
+    version: "8.1.3"
   file_selector_linux:
     dependency: transitive
     description:
@@ -548,10 +532,10 @@ packages:
     dependency: "direct main"
     description:
       name: flutter_local_notifications
-      sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
+      sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
       url: "https://pub.dev"
     source: hosted
-    version: "17.2.4"
+    version: "17.2.1+2"
   flutter_local_notifications_linux:
     dependency: transitive
     description:
@@ -1024,14 +1008,15 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.4"
-  nested:
-    dependency: transitive
+  native_video_player:
+    dependency: "direct main"
     description:
-      name: nested
-      sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
-      url: "https://pub.dev"
-    source: hosted
-    version: "1.0.0"
+      path: "."
+      ref: ac78487
+      resolved-ref: ac78487b9a87c9e72cd15b428270a905ac551f29
+      url: "https://github.com/immich-app/native_video_player"
+    source: git
+    version: "1.3.1"
   nm:
     dependency: transitive
     description:
@@ -1067,10 +1052,10 @@ packages:
     dependency: "direct main"
     description:
       name: package_info_plus
-      sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
+      sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
       url: "https://pub.dev"
     source: hosted
-    version: "8.0.3"
+    version: "8.1.1"
   package_info_plus_platform_interface:
     dependency: transitive
     description:
@@ -1255,14 +1240,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "5.0.2"
-  provider:
-    dependency: transitive
-    description:
-      name: provider
-      sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c
-      url: "https://pub.dev"
-    source: hosted
-    version: "6.1.2"
   pub_semver:
     dependency: transitive
     description:
@@ -1339,10 +1316,10 @@ packages:
     dependency: "direct main"
     description:
       name: share_plus
-      sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
+      sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
       url: "https://pub.dev"
     source: hosted
-    version: "10.0.3"
+    version: "10.1.2"
   share_plus_platform_interface:
     dependency: transitive
     description:
@@ -1708,46 +1685,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.4"
-  video_player:
-    dependency: "direct main"
-    description:
-      name: video_player
-      sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17"
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.9.2"
-  video_player_android:
-    dependency: "direct main"
-    description:
-      name: video_player_android
-      sha256: "4de50df9ee786f5891d3281e1e633d7b142ef1acf47392592eb91cba5d355849"
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.6.0"
-  video_player_avfoundation:
-    dependency: transitive
-    description:
-      name: video_player_avfoundation
-      sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.6.1"
-  video_player_platform_interface:
-    dependency: transitive
-    description:
-      name: video_player_platform_interface
-      sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6"
-      url: "https://pub.dev"
-    source: hosted
-    version: "6.2.2"
-  video_player_web:
-    dependency: transitive
-    description:
-      name: video_player_web
-      sha256: "6dcdd298136523eaf7dfc31abaf0dfba9aa8a8dbc96670e87e9d42b6f2caf774"
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.3.2"
   vm_service:
     dependency: transitive
     description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 235c58ce63..a037f9b947 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -25,9 +25,6 @@ dependencies:
   intl: ^0.19.0
   auto_route: ^9.2.0
   fluttertoast: ^8.2.4
-  video_player: ^2.9.2
-  video_player_android: 2.6.0
-  chewie: ^1.7.4
   socket_io_client: ^2.0.3+1
   maplibre_gl: 0.19.0+2
   geolocator: ^11.0.0 # used to move to current location in map view
@@ -45,7 +42,7 @@ dependencies:
   path_provider: ^2.1.2
   collection: ^1.18.0
   http_parser: ^4.0.2
-  flutter_web_auth: ^0.6.0
+  flutter_web_auth: 0.6.0
   easy_image_viewer: ^1.4.0
   isar:
     version: *isar_version
@@ -64,6 +61,10 @@ dependencies:
   async: ^2.11.0
   dynamic_color: ^1.7.0 #package to apply system theme
   background_downloader: ^8.5.5
+  native_video_player:
+    git:
+      url: https://github.com/immich-app/native_video_player
+      ref: ac78487
 
   #image editing packages
   crop_image: ^1.0.13