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