From 4dbe2cc6620b8150701924e68d8078aa6553c48b Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 2 Oct 2024 00:12:54 +0530 Subject: [PATCH] fix: handle remote asset orientation --- mobile/lib/entities/exif_info.entity.dart | 27 ++- mobile/lib/entities/exif_info.entity.g.dart | 213 +++++++++++++++++- .../common/native_video_viewer.page.dart | 74 ++++-- .../video_player_value_provider.dart | 10 +- mobile/lib/utils/migration.dart | 2 +- 5 files changed, 295 insertions(+), 31 deletions(-) diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 63d06f5d2c..583e627c5d 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 016f6d7126..2c63f91cff 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/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index df1b03f218..f6c66aa608 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -9,6 +9,7 @@ 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'; @@ -76,6 +77,16 @@ class NativeVideoViewerPage extends HookConsumerWidget { 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 @@ -93,10 +104,14 @@ class NativeVideoViewerPage extends HookConsumerWidget { // When the volume changes, set the volume ref.listen(videoPlayerControlsProvider.select((value) => value.mute), (_, mute) { - if (mute) { - controller.value?.setVolume(0.0); - } else { - controller.value?.setVolume(0.7); + try { + if (mute) { + controller.value?.setVolume(0.0); + } else { + controller.value?.setVolume(0.7); + } + } catch (_) { + // Consume error from the controller } }); @@ -110,16 +125,24 @@ class NativeVideoViewerPage extends HookConsumerWidget { // Find the position to seek to final Duration seek = asset.duration * (position / 100.0); - controller.value?.seekTo(seek.inSeconds); + 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) { - if (pause) { - controller.value?.pause(); - } else { - controller.value?.play(); + try { + if (pause) { + controller.value?.pause(); + } else { + controller.value?.play(); + } + } catch (_) { + // Consume error from the controller } }); @@ -153,8 +176,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { } void onPlaybackReady() { - controller.value?.play(); - controller.value?.setVolume(0.9); + try { + controller.value?.play(); + controller.value?.setVolume(0.9); + } catch (_) { + // Consume error from the controller + } } void onPlaybackPositionChanged() { @@ -162,8 +189,12 @@ class NativeVideoViewerPage extends HookConsumerWidget { } void onPlaybackEnded() { - if (loopVideo) { - controller.value?.play(); + try { + if (loopVideo) { + controller.value?.play(); + } + } catch (_) { + // Consume error from the controller } } @@ -199,12 +230,17 @@ class NativeVideoViewerPage extends HookConsumerWidget { return () { bufferingTimer.value.cancel(); - controller.value?.onPlaybackPositionChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackStatusChanged - .removeListener(onPlaybackPositionChanged); - controller.value?.onPlaybackReady.removeListener(onPlaybackReady); - controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded); + 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 + } }; }, [], 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 dad4659392..bffe6c7cf6 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -33,8 +33,14 @@ class VideoPlaybackValue { factory VideoPlaybackValue.fromNativeController( NativeVideoPlayerController controller, ) { - final playbackInfo = controller.playbackInfo; - final videoInfo = controller.videoInfo; + 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; 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 migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, 1);