From 9abb95d34ae0175a5cb4e39b4e4f38cdbe5d0cf6 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 15 Jul 2025 00:53:24 +0530 Subject: [PATCH] feat: handle live photos on new asset viewer (#19926) sync and handle livePhotoVideoId in asset viewer Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../drift_schemas/main/drift_schema_v1.json | Bin 27341 -> 27532 bytes .../domain/models/asset/base_asset.model.dart | 2 ++ .../models/asset/remote_asset.model.dart | 2 ++ .../entities/merged_asset.drift | 2 ++ .../entities/merged_asset.drift.dart | Bin 5454 -> 5646 bytes .../entities/remote_asset.entity.dart | 3 +++ .../entities/remote_asset.entity.drift.dart | Bin 47802 -> 50716 bytes .../repositories/sync_stream.repository.dart | 3 ++- .../repositories/timeline.repository.dart | 1 + .../motion_photo_action_button.widget.dart | 25 ++++++++++++++++++ .../asset_viewer/asset_viewer.page.dart | 12 +++++++-- .../asset_viewer/top_app_bar.widget.dart | 2 ++ .../asset_viewer/video_viewer.widget.dart | 7 ++--- .../widgets/timeline/fixed/segment.model.dart | 2 ++ 14 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 4960cd0a0d240b0e96ecff8196a300ce6e3339ac..9fc7d17d568051b3a5ec3483db27718bf2aacf8b 100644 GIT binary patch delta 58 zcmX?mm9ghK type == AssetType.image; bool get isVideo => type == AssetType.video; + bool get isMotionPhoto => livePhotoVideoId != null; + Duration get duration { final durationInSeconds = this.durationInSeconds; if (durationInSeconds != null) { diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 587eabc00..9e4cfa1f1 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -94,6 +94,7 @@ class RemoteAsset extends BaseAsset { bool? isFavorite, String? thumbHash, AssetVisibility? visibility, + String? livePhotoVideoId, }) { return RemoteAsset( id: id ?? this.id, @@ -110,6 +111,7 @@ class RemoteAsset extends BaseAsset { isFavorite: isFavorite ?? this.isFavorite, thumbHash: thumbHash ?? this.thumbHash, visibility: visibility ?? this.visibility, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, ); } } diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index d6451db8d..e07edbc0c 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -17,6 +17,7 @@ mergedAsset: SELECT * FROM rae.thumb_hash, rae.checksum, rae.owner_id, + rae.live_photo_video_id, 0 as orientation FROM remote_asset_entity rae @@ -39,6 +40,7 @@ mergedAsset: SELECT * FROM NULL as thumb_hash, lae.checksum, NULL as owner_id, + NULL as live_photo_video_id, lae.orientation FROM local_asset_entity lae diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index b289083a2b19e5c40b6d40d0bd54baad16a3fed5..4ee0643706217442f40cef55037cfd4599f12667 100644 GIT binary patch delta 156 zcmX@7)u*$eiAAI+F;y=ovn(~fAS1sdKfWw8B{hF@Ez2fG3BOPu9|gx?1q@j&HV#%k zkZ1r%OBhJUWPJ`f0StwT+(H~;!6ikRdFl3GV-OlPs|!al3YTPL7K3d^NKCF2xe5SL Cv^b&w delta 35 rcmeCvIj6OuiDmO_mbHwVQ`wkVH*e); dateTime().nullable()(); + TextColumn get livePhotoVideoId => text().nullable()(); + IntColumn get visibility => intEnum()(); @override @@ -51,6 +53,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { width: width, thumbHash: thumbHash, visibility: visibility, + livePhotoVideoId: livePhotoVideoId, localId: null, ); } diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index 4a13b74f5d6e19420078b9c2feacdb8cad0e09e8..2bb7cffe59aa6eeaa8ccd4e4132f80c3bf071fb7 100644 GIT binary patch delta 1813 zcma)6T}V@57-r{ebLuvy`*U;2w!CW7>1V72YQrk-5e(Bni3l~!ow z#qdS3pWzJ2z{J%@1%j|$bQ4kc-PBD%S6z7*)Fx5xd`H;1I(*;zKJWWJ&-1-oJJgdo zI@2k&ajlL%Har$Ob%7t@B7^P@6z1Zg-k~TT?c;)>Xjc%jlpeAkBaTRQlxhF4pHe-j zTn3z$*Q;SttpH!OQbT}wHAl!pjpYzd>o!!7F_)gwi+gNVCB&LlB%o-OT+>Mi*v)WX zUIA5pIbL$yP(rJxTwUBGNSw#vmsEjOE*E7hzUvA)34XAX8Kc3`UR6zymxnrxH$82o zz<`rsu;KKJIvBoc6Pc*TUM1)SzFul41CRMEpy@Bu+Zzkv{#Zdtf4m^2p9{`kw7{g# z4AcE(uzcA#e_n>SeTx)_q-zU5WZ`cT* z2lPz@Bo}PFW^F-!VFn%#pHL9J!Iy|TKY*IfSAt~Bq!q!#Y5u*m_ODk-#R3!C5PomgYNg z;%%Xkg`J{P5@gqft59kG$nXeh$(kr5#` zL7*dESQ$k@P9)0be?!DMILGLK$(Zn+wd`J{5W|Si3)x4ySE&RA$y7lsf;q^gw6K{{6gDNy zOw>Vay&S(=zDuDVMMmiG|$meVAhxv=B%(;J(a` b*u!~Q>dS*quZ_6-O`>GsA-U8IrmdAf+F+X4 delta 229 zcmVQWi|`5?{OjslUa;Av*UV%1ha{M zvjMYNfe;0=?uHu$vssLJ1hX2E6a$kQeG#*Vly3u*)VeRTqL}3blUa-lvlE^i1Cubt zOS84176G&4q7MVJFQ$V6v)-u00<+hvhXRvMtqGG*tO%2>Lm9KZt{MZg1hNeSvtG4Y f0+V<>6SMZWn*x(rj105jx)=tta>0WGlQ6~XMV4V5 diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 89f5c2f59..dc3b466f0 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -5,13 +5,13 @@ import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -134,6 +134,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { thumbHash: Value(asset.thumbhash), deletedAt: Value(asset.deletedAt), visibility: Value(asset.visibility.toAssetVisibility()), + livePhotoVideoId: Value(asset.livePhotoVideoId), ); batch.insert( diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 76ad9bad8..4db1d03d5 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -87,6 +87,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { height: row.height, isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds, + livePhotoVideoId: row.livePhotoVideoId, ) : LocalAsset( id: row.localId!, diff --git a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart new file mode 100644 index 000000000..47f797c16 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoActionButton extends ConsumerWidget { + const MotionPhotoActionButton({super.key, this.menuItem = true}); + + final bool menuItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + + return BaseActionButton( + iconData: isPlaying + ? Icons.motion_photos_pause_outlined + : Icons.play_circle_outline_rounded, + label: "play_motion_photo".t(context: context), + onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + menuItem: menuItem, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index b41d6ba2c..dfc002368 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widg import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -165,7 +166,7 @@ class _AssetViewerState extends ConsumerState { void _onAssetChanged(int index) { final asset = ref.read(timelineServiceProvider).getAsset(index); ref.read(currentAssetNotifier.notifier).setAsset(asset); - if (asset.isVideo) { + if (asset.isVideo || asset.isMotionPhoto) { ref.read(videoPlaybackValueProvider.notifier).reset(); ref.read(videoPlayerControlsProvider.notifier).pause(); } @@ -473,11 +474,16 @@ class _AssetViewerState extends ConsumerState { } } + void _onLongPress(_, __, ___) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + } + PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { scaffoldContext ??= ctx; final asset = ref.read(timelineServiceProvider).getAsset(index); + final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); - if (asset.isImage) { + if (asset.isImage && !isPlayingMotionVideo) { return _imageBuilder(ctx, asset); } @@ -500,6 +506,7 @@ class _AssetViewerState extends ConsumerState { onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onTapDown: _onTapDown, + onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, errorBuilder: (_, __, ___) => Container( width: ctx.width, height: ctx.height, @@ -561,6 +568,7 @@ class _AssetViewerState extends ConsumerState { // Using multiple selectors to avoid unnecessary rebuilds for other state changes ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); + ref.watch(isPlayingMotionVideoProvider); // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Issue: https://github.com/flutter/flutter/issues/109037 diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index b7e847707..0f3d46f67 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -44,6 +45,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { source: ActionSource.viewer, menuItem: true, ), + if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true), const _KebabMenu(), ]; 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 60cb61b25..17880da3e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -270,10 +270,7 @@ class NativeVideoViewer extends HookConsumerWidget { return; } - if (videoController.playbackInfo?.status == PlaybackStatus.stopped && - !ref - .read(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.loopVideo)) { + if (videoController.playbackInfo?.status == PlaybackStatus.stopped) { ref.read(isPlayingMotionVideoProvider.notifier).playing = false; } } @@ -310,7 +307,7 @@ class NativeVideoViewer extends HookConsumerWidget { final loopVideo = ref .read(appSettingsServiceProvider) .getSetting(AppSettingsEnum.loopVideo); - nc.setLoop(loopVideo); + nc.setLoop(!asset.isMotionPhoto && loopVideo); controller.value = nc; Timer(const Duration(milliseconds: 200), checkIfBuffering); diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index d12f82d27..7fff6d7d2 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -171,6 +172,7 @@ class _AssetTileWidget extends ConsumerWidget { ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); } else { await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1); + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; ctx.pushRoute( AssetViewerRoute( initialIndex: assetIndex,