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>
This commit is contained in:
shenlong 2025-07-15 00:53:24 +05:30 committed by GitHub
parent 805ec3e351
commit 9abb95d34a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 53 additions and 8 deletions

Binary file not shown.

View file

@ -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) {

View file

@ -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,
);
}
}

View file

@ -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

View file

@ -30,6 +30,8 @@ class RemoteAssetEntity extends Table
DateTimeColumn get deletedAt => dateTime().nullable()();
TextColumn get livePhotoVideoId => text().nullable()();
IntColumn get visibility => intEnum<AssetVisibility>()();
@override
@ -51,6 +53,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
width: width,
thumbHash: thumbHash,
visibility: visibility,
livePhotoVideoId: livePhotoVideoId,
localId: null,
);
}

View file

@ -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(

View file

@ -87,6 +87,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
height: row.height,
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId,
)
: LocalAsset(
id: row.localId!,

View file

@ -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,
);
}
}

View file

@ -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<AssetViewer> {
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<AssetViewer> {
}
}
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<AssetViewer> {
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<AssetViewer> {
// 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

View file

@ -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(),
];

View file

@ -270,10 +270,7 @@ class NativeVideoViewer extends HookConsumerWidget {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
!ref
.read(appSettingsServiceProvider)
.getSetting<bool>(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<bool>(AppSettingsEnum.loopVideo);
nc.setLoop(loopVideo);
nc.setLoop(!asset.isMotionPhoto && loopVideo);
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);

View file

@ -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,