diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 4960cd0a0..9fc7d17d5 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index fad1f1961..356b64cff 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -43,6 +43,8 @@ sealed class BaseAsset { bool get isImage => 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 b289083a2..4ee064370 100644 Binary files a/mobile/lib/infrastructure/entities/merged_asset.drift.dart and b/mobile/lib/infrastructure/entities/merged_asset.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index c08401356..96193e041 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -30,6 +30,8 @@ class RemoteAssetEntity extends Table DateTimeColumn get deletedAt => 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 4a13b74f5..2bb7cffe5 100644 Binary files a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart differ 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,