diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index 6a9d34ab83bfe..c1d158a5398df 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):
@@ -116,6 +118,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`)
@@ -170,6 +173,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:
@@ -212,6 +217,7 @@ SPEC CHECKSUMS:
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
+ native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart
index 8be636efb659b..23bf23604635d 100644
--- a/mobile/lib/entities/asset.entity.g.dart
+++ b/mobile/lib/entities/asset.entity.g.dart
@@ -57,64 +57,69 @@ const AssetSchema = CollectionSchema(
name: r'isFavorite',
type: IsarType.bool,
),
- r'isTrashed': PropertySchema(
+ r'isOffline': PropertySchema(
id: 8,
+ name: r'isOffline',
+ type: IsarType.bool,
+ ),
+ r'isTrashed': PropertySchema(
+ id: 9,
name: r'isTrashed',
type: IsarType.bool,
),
r'livePhotoVideoId': PropertySchema(
- id: 9,
+ id: 10,
name: r'livePhotoVideoId',
type: IsarType.string,
),
r'localId': PropertySchema(
- id: 10,
+ id: 11,
name: r'localId',
type: IsarType.string,
),
r'ownerId': PropertySchema(
- id: 11,
+ id: 12,
name: r'ownerId',
type: IsarType.long,
),
r'remoteId': PropertySchema(
- id: 12,
+ id: 13,
name: r'remoteId',
type: IsarType.string,
),
r'stackCount': PropertySchema(
- id: 13,
+ id: 14,
name: r'stackCount',
type: IsarType.long,
),
r'stackId': PropertySchema(
- id: 14,
+ id: 15,
name: r'stackId',
type: IsarType.string,
),
r'stackPrimaryAssetId': PropertySchema(
- id: 15,
+ id: 16,
name: r'stackPrimaryAssetId',
type: IsarType.string,
),
r'thumbhash': PropertySchema(
- id: 16,
+ id: 17,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
- id: 17,
+ id: 18,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
- id: 18,
+ id: 19,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
- id: 19,
+ id: 20,
name: r'width',
type: IsarType.int,
)
@@ -239,18 +244,19 @@ void _assetSerialize(
writer.writeInt(offsets[5], object.height);
writer.writeBool(offsets[6], object.isArchived);
writer.writeBool(offsets[7], object.isFavorite);
- writer.writeBool(offsets[8], object.isTrashed);
- writer.writeString(offsets[9], object.livePhotoVideoId);
- writer.writeString(offsets[10], object.localId);
- writer.writeLong(offsets[11], object.ownerId);
- writer.writeString(offsets[12], object.remoteId);
- writer.writeLong(offsets[13], object.stackCount);
- writer.writeString(offsets[14], object.stackId);
- writer.writeString(offsets[15], object.stackPrimaryAssetId);
- writer.writeString(offsets[16], object.thumbhash);
- writer.writeByte(offsets[17], object.type.index);
- writer.writeDateTime(offsets[18], object.updatedAt);
- writer.writeInt(offsets[19], object.width);
+ writer.writeBool(offsets[8], object.isOffline);
+ writer.writeBool(offsets[9], object.isTrashed);
+ writer.writeString(offsets[10], object.livePhotoVideoId);
+ writer.writeString(offsets[11], object.localId);
+ writer.writeLong(offsets[12], object.ownerId);
+ writer.writeString(offsets[13], object.remoteId);
+ writer.writeLong(offsets[14], object.stackCount);
+ writer.writeString(offsets[15], object.stackId);
+ writer.writeString(offsets[16], object.stackPrimaryAssetId);
+ writer.writeString(offsets[17], object.thumbhash);
+ writer.writeByte(offsets[18], object.type.index);
+ writer.writeDateTime(offsets[19], object.updatedAt);
+ writer.writeInt(offsets[20], object.width);
}
Asset _assetDeserialize(
@@ -269,19 +275,20 @@ Asset _assetDeserialize(
id: id,
isArchived: reader.readBoolOrNull(offsets[6]) ?? false,
isFavorite: reader.readBoolOrNull(offsets[7]) ?? false,
- isTrashed: reader.readBoolOrNull(offsets[8]) ?? false,
- livePhotoVideoId: reader.readStringOrNull(offsets[9]),
- localId: reader.readStringOrNull(offsets[10]),
- ownerId: reader.readLong(offsets[11]),
- remoteId: reader.readStringOrNull(offsets[12]),
- stackCount: reader.readLongOrNull(offsets[13]) ?? 0,
- stackId: reader.readStringOrNull(offsets[14]),
- stackPrimaryAssetId: reader.readStringOrNull(offsets[15]),
- thumbhash: reader.readStringOrNull(offsets[16]),
- type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
+ isOffline: reader.readBoolOrNull(offsets[8]) ?? false,
+ isTrashed: reader.readBoolOrNull(offsets[9]) ?? false,
+ livePhotoVideoId: reader.readStringOrNull(offsets[10]),
+ localId: reader.readStringOrNull(offsets[11]),
+ ownerId: reader.readLong(offsets[12]),
+ remoteId: reader.readStringOrNull(offsets[13]),
+ stackCount: reader.readLongOrNull(offsets[14]) ?? 0,
+ stackId: reader.readStringOrNull(offsets[15]),
+ stackPrimaryAssetId: reader.readStringOrNull(offsets[16]),
+ thumbhash: reader.readStringOrNull(offsets[17]),
+ type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other,
- updatedAt: reader.readDateTime(offsets[18]),
- width: reader.readIntOrNull(offsets[19]),
+ updatedAt: reader.readDateTime(offsets[19]),
+ width: reader.readIntOrNull(offsets[20]),
);
return object;
}
@@ -312,27 +319,29 @@ P _assetDeserializeProp
(
case 8:
return (reader.readBoolOrNull(offset) ?? false) as P;
case 9:
- return (reader.readStringOrNull(offset)) as P;
+ return (reader.readBoolOrNull(offset) ?? false) as P;
case 10:
return (reader.readStringOrNull(offset)) as P;
case 11:
- return (reader.readLong(offset)) as P;
+ return (reader.readStringOrNull(offset)) as P;
case 12:
- return (reader.readStringOrNull(offset)) as P;
+ return (reader.readLong(offset)) as P;
case 13:
- return (reader.readLongOrNull(offset) ?? 0) as P;
- case 14:
return (reader.readStringOrNull(offset)) as P;
+ case 14:
+ return (reader.readLongOrNull(offset) ?? 0) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
case 17:
+ return (reader.readStringOrNull(offset)) as P;
+ case 18:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
- case 18:
- return (reader.readDateTime(offset)) as P;
case 19:
+ return (reader.readDateTime(offset)) as P;
+ case 20:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -1353,6 +1362,16 @@ extension AssetQueryFilter on QueryBuilder {
});
}
+ QueryBuilder isOfflineEqualTo(
+ bool value) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'isOffline',
+ value: value,
+ ));
+ });
+ }
+
QueryBuilder isTrashedEqualTo(
bool value) {
return QueryBuilder.apply(this, (query) {
@@ -2628,6 +2647,18 @@ extension AssetQuerySortBy on QueryBuilder {
});
}
+ QueryBuilder sortByIsOffline() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'isOffline', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByIsOfflineDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'isOffline', Sort.desc);
+ });
+ }
+
QueryBuilder sortByIsTrashed() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isTrashed', Sort.asc);
@@ -2882,6 +2913,18 @@ extension AssetQuerySortThenBy on QueryBuilder {
});
}
+ QueryBuilder thenByIsOffline() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'isOffline', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByIsOfflineDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'isOffline', Sort.desc);
+ });
+ }
+
QueryBuilder thenByIsTrashed() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isTrashed', Sort.asc);
@@ -3078,6 +3121,12 @@ extension AssetQueryWhereDistinct on QueryBuilder {
});
}
+ QueryBuilder distinctByIsOffline() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'isOffline');
+ });
+ }
+
QueryBuilder distinctByIsTrashed() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'isTrashed');
@@ -3214,6 +3263,12 @@ extension AssetQueryProperty on QueryBuilder {
});
}
+ QueryBuilder isOfflineProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'isOffline');
+ });
+ }
+
QueryBuilder isTrashedProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isTrashed');
diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart
index 63d06f5d2c1aa..583e627c5d543 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,9 @@ class ExifInfo {
@ignore
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
+ @ignore
+ bool get isFlipped => _isOrientationFlipped(orientation);
+
@ignore
double? get latitude => lat;
@@ -67,7 +71,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 +92,7 @@ class ExifInfo {
this.state,
this.country,
this.description,
+ this.orientation,
});
ExifInfo copyWith({
@@ -107,6 +113,7 @@ class ExifInfo {
String? state,
String? country,
String? description,
+ String? orientation,
}) =>
ExifInfo(
id: id ?? this.id,
@@ -126,6 +133,7 @@ class ExifInfo {
state: state ?? this.state,
country: country ?? this.country,
description: description ?? this.description,
+ orientation: orientation ?? this.orientation,
);
@override
@@ -147,7 +155,8 @@ class ExifInfo {
city == other.city &&
state == other.state &&
country == other.country &&
- description == other.description;
+ description == other.description &&
+ orientation == other.orientation;
}
@override
@@ -169,7 +178,8 @@ class ExifInfo {
city.hashCode ^
state.hashCode ^
country.hashCode ^
- description.hashCode;
+ description.hashCode ^
+ orientation.hashCode;
@override
String toString() {
@@ -192,10 +202,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 016f6d71260d0..2c63f91cff258 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(
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 orientationIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'orientation',
+ ));
+ });
+ }
+
+ QueryBuilder
+ orientationIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'orientation',
+ ));
+ });
+ }
+
+ QueryBuilder orientationEqualTo(
+ String? value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'orientation',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ 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 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 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 orientationStartsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.startsWith(
+ property: r'orientation',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder orientationEndsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.endsWith(
+ property: r'orientation',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder orientationContains(
+ String value,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.contains(
+ property: r'orientation',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder orientationMatches(
+ String pattern,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.matches(
+ property: r'orientation',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder orientationIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'orientation',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder
+ orientationIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ property: r'orientation',
+ value: '',
+ ));
+ });
+ }
+
QueryBuilder stateIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder {
});
}
+ QueryBuilder sortByOrientation() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'orientation', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByOrientationDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'orientation', Sort.desc);
+ });
+ }
+
QueryBuilder sortByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy
});
}
+ QueryBuilder thenByOrientation() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'orientation', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByOrientationDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'orientation', Sort.desc);
+ });
+ }
+
QueryBuilder thenByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct
});
}
+ QueryBuilder distinctByOrientation(
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
+ });
+ }
+
QueryBuilder distinctByState(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty
});
}
+ QueryBuilder orientationProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'orientation');
+ });
+ }
+
QueryBuilder stateProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'state');
diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart
index 57c75ca84df84..4a4f2f2939960 100644
--- a/mobile/lib/pages/common/gallery_viewer.page.dart
+++ b/mobile/lib/pages/common/gallery_viewer.page.dart
@@ -11,8 +11,8 @@ 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/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
-import 'package:immich_mobile/pages/common/video_viewer.page.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';
@@ -62,7 +62,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final localPosition = useState(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;
@@ -367,7 +366,7 @@ class GalleryViewerPage extends HookConsumerWidget {
maxScale: 1.0,
minScale: 1.0,
basePosition: Alignment.center,
- child: VideoViewerPage(
+ child: NativeVideoViewerPage(
key: ValueKey(a),
asset: a,
isMotionVideo: a.livePhotoVideoId != null,
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 0000000000000..f6c66aa608591
--- /dev/null
+++ b/mobile/lib/pages/common/native_video_viewer.page.dart
@@ -0,0 +1,308 @@
+import 'dart:async';
+
+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/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/services/api.service.dart';
+import 'package:immich_mobile/services/asset.service.dart';
+import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
+import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
+import 'package:native_video_player/native_video_player.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
+
+class NativeVideoViewerPage extends HookConsumerWidget {
+ final Asset asset;
+ final bool isMotionVideo;
+ final Widget? placeholder;
+ final bool showControls;
+ final Duration hideControlsTimer;
+ final bool loopVideo;
+
+ const NativeVideoViewerPage({
+ super.key,
+ required this.asset,
+ this.isMotionVideo = false,
+ this.placeholder,
+ this.showControls = true,
+ this.hideControlsTimer = const Duration(seconds: 5),
+ this.loopVideo = false,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final controller = useState(null);
+ final lastVideoPosition = useRef(-1);
+ final isBuffering = useRef(false);
+ final width = useRef(asset.width?.toDouble() ?? 1.0);
+ final height = useRef(asset.height?.toDouble() ?? 1.0);
+
+ void checkIfBuffering([Timer? timer]) {
+ if (!context.mounted) {
+ timer?.cancel();
+ 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
+ final bufferingTimer = useRef(
+ Timer.periodic(const Duration(seconds: 5), checkIfBuffering),
+ );
+
+ Future createSource(Asset asset) async {
+ if (asset.isLocal && asset.livePhotoVideoId == null) {
+ final entity = await asset.local!.obtainForNewProperties();
+ final file = await entity?.file;
+ if (entity == null || file == null) {
+ throw Exception('No file found for the video');
+ }
+
+ width.value = entity.orientatedWidth.toDouble();
+ height.value = entity.orientatedHeight.toDouble();
+
+ return await VideoSource.init(
+ path: file.path,
+ type: VideoSourceType.file,
+ );
+ } else {
+ final assetWithExif =
+ await ref.read(assetServiceProvider).loadExif(asset);
+ final shouldFlip = assetWithExif.exifInfo?.isFlipped ?? false;
+ width.value = (shouldFlip ? assetWithExif.height : assetWithExif.width)
+ ?.toDouble() ??
+ width.value;
+ height.value = (shouldFlip ? assetWithExif.width : assetWithExif.height)
+ ?.toDouble() ??
+ height.value;
+
+ // 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';
+
+ return await VideoSource.init(
+ path: videoUrl,
+ type: VideoSourceType.network,
+ headers: ApiService.getRequestHeaders(),
+ );
+ }
+ }
+
+ // When the volume changes, set the volume
+ ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
+ (_, mute) {
+ try {
+ if (mute) {
+ controller.value?.setVolume(0.0);
+ } else {
+ controller.value?.setVolume(0.7);
+ }
+ } catch (_) {
+ // Consume error from the controller
+ }
+ });
+
+ // When the position changes, seek to the position
+ ref.listen(videoPlayerControlsProvider.select((value) => value.position),
+ (_, position) {
+ if (controller.value == null) {
+ // No seeeking if there is no video
+ return;
+ }
+
+ // Find the position to seek to
+ final Duration seek = asset.duration * (position / 100.0);
+ try {
+ controller.value?.seekTo(seek.inSeconds);
+ } catch (_) {
+ // Consume error from the controller
+ }
+ });
+
+ // When the custom video controls paus or plays
+ ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
+ (_, pause) {
+ try {
+ if (pause) {
+ controller.value?.pause();
+ } else {
+ controller.value?.play();
+ }
+ } catch (_) {
+ // Consume error from the controller
+ }
+ });
+
+ void updateVideoPlayback() {
+ if (controller.value == null || !context.mounted) {
+ return;
+ }
+
+ final videoPlayback =
+ VideoPlaybackValue.fromNativeController(controller.value!);
+ ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
+ // Check if the video is buffering
+ if (videoPlayback.state == VideoPlaybackState.playing) {
+ isBuffering.value =
+ lastVideoPosition.value == videoPlayback.position.inSeconds;
+ lastVideoPosition.value = videoPlayback.position.inSeconds;
+ } else {
+ isBuffering.value = false;
+ lastVideoPosition.value = -1;
+ }
+ 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();
+ }
+ }
+
+ void onPlaybackReady() {
+ try {
+ controller.value?.play();
+ controller.value?.setVolume(0.9);
+ } catch (_) {
+ // Consume error from the controller
+ }
+ }
+
+ void onPlaybackPositionChanged() {
+ updateVideoPlayback();
+ }
+
+ void onPlaybackEnded() {
+ try {
+ if (loopVideo) {
+ controller.value?.play();
+ }
+ } catch (_) {
+ // Consume error from the controller
+ }
+ }
+
+ Future initController(NativeVideoPlayerController nc) async {
+ if (controller.value != null) {
+ return;
+ }
+
+ nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
+ nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged);
+ nc.onPlaybackReady.addListener(onPlaybackReady);
+ nc.onPlaybackEnded.addListener(onPlaybackEnded);
+
+ final videoSource = await createSource(asset);
+ nc.loadVideoSource(videoSource);
+
+ controller.value = nc;
+ Timer(const Duration(milliseconds: 200), checkIfBuffering);
+ }
+
+ useEffect(
+ () {
+ Future.microtask(
+ () => ref.read(videoPlayerControlsProvider.notifier).reset(),
+ );
+
+ if (isMotionVideo) {
+ // ignore: prefer-extracting-callbacks
+ Future.microtask(() {
+ ref.read(showControlsProvider.notifier).show = false;
+ });
+ }
+
+ return () {
+ bufferingTimer.value.cancel();
+ try {
+ controller.value?.onPlaybackPositionChanged
+ .removeListener(onPlaybackPositionChanged);
+ controller.value?.onPlaybackStatusChanged
+ .removeListener(onPlaybackPositionChanged);
+ controller.value?.onPlaybackReady.removeListener(onPlaybackReady);
+ controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded);
+ controller.value?.stop();
+ } catch (_) {
+ // Consume error from the controller
+ }
+ };
+ },
+ [],
+ );
+
+ double calculateAspectRatio() {
+ if (width.value == 0 || height.value == 0) {
+ return 1;
+ }
+ return width.value / height.value;
+ }
+
+ final size = MediaQuery.sizeOf(context);
+
+ return SizedBox(
+ height: size.height,
+ width: size.width,
+ child: GestureDetector(
+ behavior: HitTestBehavior.deferToChild,
+ child: PopScope(
+ onPopInvokedWithResult: (didPop, _) => ref
+ .read(videoPlaybackValueProvider.notifier)
+ .value = VideoPlaybackValue.uninitialized(),
+ child: SizedBox(
+ height: size.height,
+ width: size.width,
+ child: Stack(
+ children: [
+ Center(
+ child: AspectRatio(
+ aspectRatio: calculateAspectRatio(),
+ child: NativeVideoPlayerView(
+ onViewReady: initController,
+ ),
+ ),
+ ),
+ if (showControls)
+ Center(
+ child: CustomVideoPlayerControls(
+ hideTimerDuration: hideControlsTimer,
+ ),
+ ),
+ Visibility(
+ visible: controller.value == null,
+ child: Stack(
+ children: [
+ if (placeholder != null) placeholder!,
+ const Positioned.fill(
+ child: Center(
+ child: DelayedLoadingIndicator(
+ fadeInDuration: Duration(milliseconds: 500),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
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 ebdf739ef03de..bffe6c7cf6f9f 100644
--- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart
+++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart
@@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:native_video_player/native_video_player.dart';
import 'package:video_player/video_player.dart';
enum VideoPlaybackState {
@@ -29,6 +30,38 @@ class VideoPlaybackValue {
required this.volume,
});
+ factory VideoPlaybackValue.fromNativeController(
+ NativeVideoPlayerController controller,
+ ) {
+ PlaybackInfo? playbackInfo;
+ VideoInfo? videoInfo;
+ try {
+ playbackInfo = controller.playbackInfo;
+ videoInfo = controller.videoInfo;
+ } catch (_) {
+ // Consume error from the controller
+ }
+ late VideoPlaybackState s;
+ if (playbackInfo?.status == null) {
+ s = VideoPlaybackState.initializing;
+ } else if (playbackInfo?.status == PlaybackStatus.stopped &&
+ (playbackInfo?.positionFraction == 1 ||
+ playbackInfo?.positionFraction == 0)) {
+ s = VideoPlaybackState.completed;
+ } else if (playbackInfo?.status == PlaybackStatus.playing) {
+ s = VideoPlaybackState.playing;
+ } else {
+ s = VideoPlaybackState.paused;
+ }
+
+ return VideoPlaybackValue(
+ position: Duration(seconds: playbackInfo?.position ?? 0),
+ duration: Duration(seconds: videoInfo?.duration ?? 0),
+ state: s,
+ volume: playbackInfo?.volume ?? 0.0,
+ );
+ }
+
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
final video = controller?.value;
late VideoPlaybackState s;
@@ -60,6 +93,20 @@ class VideoPlaybackValue {
volume: 0.0,
);
}
+
+ VideoPlaybackValue copyWith({
+ Duration? position,
+ Duration? duration,
+ VideoPlaybackState? state,
+ double? volume,
+ }) {
+ return VideoPlaybackValue(
+ position: position ?? this.position,
+ duration: duration ?? this.duration,
+ state: state ?? this.state,
+ volume: volume ?? this.volume,
+ );
+ }
}
final videoPlaybackValueProvider =
diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart
index 2b02a5ff8f290..67ff060075383 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 migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);
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 a34fcb9baf5e0..d53f268ae531a 100644
--- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart
+++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart
@@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.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;
@@ -86,12 +86,8 @@ 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,
diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart
index fb7cc882a0d31..138ee6debbe1c 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,10 +68,9 @@ class MemoryCard extends StatelessWidget {
} else {
return Hero(
tag: 'memory-${asset.id}',
- child: VideoViewerPage(
+ child: NativeVideoViewerPage(
key: ValueKey(asset),
asset: asset,
- showDownloadingIndicator: false,
placeholder: SizedBox.expand(
child: ImmichImage(
asset,
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 1088011667dc1..609f210efa958 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -1024,6 +1024,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
+ native_video_player:
+ dependency: "direct main"
+ description:
+ path: "."
+ ref: "feat/headers"
+ resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed"
+ url: "https://github.com/immich-app/native_video_player"
+ source: git
+ version: "1.3.1"
nested:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 67db6cbd6cc8b..fdd716160a7b6 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -56,8 +56,15 @@ dependencies:
thumbhash: 0.1.0+1
async: ^2.11.0
dynamic_color: ^1.7.0 #package to apply system theme
+
+ native_video_player:
+ git:
+ url: https://github.com/immich-app/native_video_player
+ ref: feat/headers
+
background_downloader: ^8.5.5
+
#image editing packages
crop_image: ^1.0.13