diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 418b124930..b458a64a7f 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -27,7 +27,6 @@ sealed class BaseAsset { final int? height; final int? durationMs; final bool isFavorite; - final String? livePhotoVideoId; final bool isEdited; const BaseAsset({ @@ -40,32 +39,15 @@ sealed class BaseAsset { this.height, this.durationMs, this.isFavorite = false, - this.livePhotoVideoId, required this.isEdited, }); bool get isImage => type == AssetType.image; bool get isVideo => type == AssetType.video; - bool get isMotionPhoto => livePhotoVideoId != null; + bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto; bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated; - AssetPlaybackStyle get playbackStyle { - if (isVideo) { - return AssetPlaybackStyle.video; - } - if (isMotionPhoto) { - return AssetPlaybackStyle.livePhoto; - } - if (isImage && durationMs != null && durationMs! > 0) { - return AssetPlaybackStyle.imageAnimated; - } - if (isImage) { - return AssetPlaybackStyle.image; - } - return AssetPlaybackStyle.unknown; - } - Duration get duration { final durationMs = this.durationMs; if (durationMs != null) { @@ -86,6 +68,7 @@ sealed class BaseAsset { String? get localId; String? get remoteId; String get heroTag; + AssetPlaybackStyle get playbackStyle; @override String toString() { diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 04f0ae6c8c..0ad4c8f39b 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -25,7 +25,6 @@ class LocalAsset extends BaseAsset { super.height, super.durationMs, super.isFavorite = false, - super.livePhotoVideoId, this.orientation = 0, required this.playbackStyle, this.adjustmentTime, diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index b370825fdd..3709627cfb 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset { final AssetVisibility visibility; final String ownerId; final String? stackId; + final String? livePhotoVideoId; final DateTime? uploadedAt; final DateTime? deletedAt; @@ -29,7 +30,7 @@ class RemoteAsset extends BaseAsset { super.isFavorite = false, this.thumbHash, this.visibility = AssetVisibility.timeline, - super.livePhotoVideoId, + this.livePhotoVideoId, this.stackId, required super.isEdited, this.deletedAt, @@ -38,6 +39,23 @@ class RemoteAsset extends BaseAsset { @override String? get localId => localAssetId; + @override + AssetPlaybackStyle get playbackStyle { + if (isVideo) { + return AssetPlaybackStyle.video; + } + if (livePhotoVideoId != null) { + return AssetPlaybackStyle.livePhoto; + } + if (isImage && durationMs != null && durationMs! > 0) { + return AssetPlaybackStyle.imageAnimated; + } + if (isImage) { + return AssetPlaybackStyle.image; + } + return AssetPlaybackStyle.unknown; + } + @override String? get remoteId => id; @@ -90,6 +108,7 @@ class RemoteAsset extends BaseAsset { thumbHash == other.thumbHash && visibility == other.visibility && stackId == other.stackId && + livePhotoVideoId == other.livePhotoVideoId && uploadedAt == other.uploadedAt && deletedAt == other.deletedAt; } @@ -103,6 +122,7 @@ class RemoteAsset extends BaseAsset { thumbHash.hashCode ^ visibility.hashCode ^ stackId.hashCode ^ + livePhotoVideoId.hashCode ^ uploadedAt.hashCode ^ deletedAt.hashCode; diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index ca16d1f980..9ba11fd6a7 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -26,6 +26,10 @@ class AssetService { return _localRepository.getByChecksum(checksum); } + Future getLocalAsset(String id) { + return _localRepository.get(id); + } + Future getRemoteAssetByChecksum(String checksum) { return _remoteRepository.getByChecksum(checksum); } diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index eaa6ce79f7..3a63812485 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -33,6 +33,7 @@ class StorageRepository { return file; } + // TODO(agg23): Unify these methods Future getMotionFileForAsset(LocalAsset asset) async { File? file; final log = Logger('StorageRepository'); diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index 20021bada1..3e5b603451 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -114,7 +114,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection _PropertyItem(label: 'Height', value: asset.height?.toString()), _PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null), _PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), - _PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId), _PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()), ]); } @@ -151,6 +150,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection _PropertyItem(label: 'Thumb Hash', value: asset.thumbHash), _PropertyItem(label: 'Visibility', value: asset.visibility.toString()), _PropertyItem(label: 'Stack ID', value: asset.stackId), + _PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId), ]; properties.insertAll(4, additionalProps); diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index cc14788b9f..87f97d5754 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -122,9 +122,14 @@ class _NativeVideoViewerState extends ConsumerState with Widg ); } - if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { - final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await StorageRepository().getFileForAsset(id); + // Attempt to retrieve LocalAsset, falling back to remote if it cannot be found + final localAsset = await _localPlaybackAsset(videoAsset); + + if (localAsset != null) { + final file = localAsset.isMotionPhoto + ? await StorageRepository().getMotionFileForAsset(localAsset) + : await StorageRepository().getFileForAsset(localAsset.id); + if (!mounted) { return null; } @@ -141,14 +146,13 @@ class _NativeVideoViewerState extends ConsumerState with Widg ); } - final remoteId = (videoAsset as RemoteAsset).id; + final remoteAsset = videoAsset as RemoteAsset; final serverEndpoint = Store.get(StoreKey.serverEndpoint); final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; - final String videoUrl = videoAsset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' - : '$serverEndpoint/assets/$remoteId/$postfixUrl'; + final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id; + final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl'; return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); } catch (error) { @@ -157,6 +161,43 @@ class _NativeVideoViewerState extends ConsumerState with Widg } } + Future _localPlaybackAsset(BaseAsset baseAsset) async { + if (!baseAsset.hasLocal) { + return null; + } + + LocalAsset? localAsset; + + if (baseAsset is LocalAsset) { + localAsset = baseAsset; + } else { + final localId = (baseAsset as RemoteAsset).localId; + localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null; + } + + if (localAsset == null) { + _log.severe( + 'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved', + ); + + return null; + } + + // Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local + // have the same asset (hash), but only the remote properly recognizes it as a motion asset + // If this scenario occurs, fall back to using the remote asset + if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) { + // Platform mismatch for motion photo, use remote instead + _log.warning( + 'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}', + ); + + return null; + } + + return localAsset; + } + void _onPlaybackReady() async { if (!mounted || !widget.isCurrent) { return; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 4803e26b86..c42d365464 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -297,8 +297,7 @@ class _AssetTypeIcons extends StatelessWidget { @override Widget build(BuildContext context) { - final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null; - final isLivePhoto = remoteAsset?.livePhotoVideoId != null; + final isLivePhoto = asset.isMotionPhoto; return Column( mainAxisSize: MainAxisSize.min, diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index f604f53c3e..94b3413c9b 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -70,4 +70,36 @@ void main() { expect((assets.first as RemoteAsset).id, asset.id); }); }); + + group('live photos', () { + test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async { + final user = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1'); + + final assets = await sut.main([user.id], .day).assetSource(0, 10); + + expect(assets, hasLength(1)); + final remote = assets.single as RemoteAsset; + expect(remote.id, asset.id); + expect(remote.livePhotoVideoId, 'motion-photo-1'); + expect(remote.isMotionPhoto, isTrue); + expect(remote.localId, isNull); + }); + + test('merged live photo resolves localId and is marked as a motion photo', () async { + final user = await ctx.newUser(); + const checksum = 'shared-live-photo-checksum'; + final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2'); + final local = await ctx.newLocalAsset(checksum: checksum); + + final assets = await sut.main([user.id], .day).assetSource(0, 10); + + expect(assets, hasLength(1)); + final remote = assets.single as RemoteAsset; + expect(remote.id, asset.id); + expect(remote.livePhotoVideoId, 'motion-photo-2'); + expect(remote.isMotionPhoto, isTrue); + expect(remote.localId, local.id); + }); + }); }