fix: mobile edit handling (#25315)

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
Brandon Wees 2026-01-19 12:22:53 -06:00 committed by GitHub
parent b3f5b8ede8
commit 1b56bb84f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 420 additions and 155 deletions

Binary file not shown.

View file

@ -22,6 +22,7 @@ sealed class BaseAsset {
final int? durationInSeconds; final int? durationInSeconds;
final bool isFavorite; final bool isFavorite;
final String? livePhotoVideoId; final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({ const BaseAsset({
required this.name, required this.name,
@ -34,6 +35,7 @@ sealed class BaseAsset {
this.durationInSeconds, this.durationInSeconds,
this.isFavorite = false, this.isFavorite = false,
this.livePhotoVideoId, this.livePhotoVideoId,
required this.isEdited,
}); });
bool get isImage => type == AssetType.image; bool get isImage => type == AssetType.image;
@ -71,6 +73,7 @@ sealed class BaseAsset {
height: ${height ?? "<NA>"}, height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"}, durationInSeconds: ${durationInSeconds ?? "<NA>"},
isFavorite: $isFavorite, isFavorite: $isFavorite,
isEdited: $isEdited,
}'''; }''';
} }
@ -85,7 +88,8 @@ sealed class BaseAsset {
width == other.width && width == other.width &&
height == other.height && height == other.height &&
durationInSeconds == other.durationInSeconds && durationInSeconds == other.durationInSeconds &&
isFavorite == other.isFavorite; isFavorite == other.isFavorite &&
isEdited == other.isEdited;
} }
return false; return false;
} }
@ -99,6 +103,7 @@ sealed class BaseAsset {
width.hashCode ^ width.hashCode ^
height.hashCode ^ height.hashCode ^
durationInSeconds.hashCode ^ durationInSeconds.hashCode ^
isFavorite.hashCode; isFavorite.hashCode ^
isEdited.hashCode;
} }
} }

View file

@ -28,6 +28,7 @@ class LocalAsset extends BaseAsset {
this.adjustmentTime, this.adjustmentTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
required super.isEdited,
}) : remoteAssetId = remoteId; }) : remoteAssetId = remoteId;
@override @override
@ -107,6 +108,7 @@ class LocalAsset extends BaseAsset {
DateTime? adjustmentTime, DateTime? adjustmentTime,
double? latitude, double? latitude,
double? longitude, double? longitude,
bool? isEdited,
}) { }) {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
@ -125,6 +127,7 @@ class LocalAsset extends BaseAsset {
adjustmentTime: adjustmentTime ?? this.adjustmentTime, adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
); );
} }
} }

View file

@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited,
}) : localAssetId = localId; }) : localAssetId = localId;
@override @override
@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset {
AssetVisibility? visibility, AssetVisibility? visibility,
String? livePhotoVideoId, String? livePhotoVideoId,
String? stackId, String? stackId,
bool? isEdited,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: id ?? this.id, id: id ?? this.id,
@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset {
visibility: visibility ?? this.visibility, visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
); );
} }
} }

View file

@ -436,5 +436,6 @@ extension PlatformToLocalAsset on PlatformAsset {
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true), adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
isEdited: false,
); );
} }

View file

@ -77,6 +77,7 @@ extension on AssetResponseDto {
thumbHash: thumbhash, thumbHash: thumbhash,
localId: null, localId: null,
type: type.toAssetType(), type: type.toAssetType(),
isEdited: isEdited,
); );
} }
} }

View file

@ -247,6 +247,42 @@ class SyncStreamService {
} }
} }
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
if (assetData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
if (asset != null) {
assets.add(asset);
}
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async { Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) { if (checksums.isEmpty) {
return Future.value(); return Future.value();

View file

@ -196,6 +196,16 @@ class BackgroundSyncManager {
}); });
} }
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() { Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) { if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future; return _linkedAlbumSyncTask!.future;
@ -231,3 +241,8 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData), computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData),
debugLabel: 'websocket-batch', debugLabel: 'websocket-batch',
); );
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
debugLabel: 'websocket-edit',
);

View file

@ -47,5 +47,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
cloudId: iCloudId, cloudId: iCloudId,
isEdited: false,
); );
} }

View file

@ -25,7 +25,8 @@ SELECT
NULL as i_cloud_id, NULL as i_cloud_id,
NULL as latitude, NULL as latitude,
NULL as longitude, NULL as longitude,
NULL as adjustmentTime NULL as adjustmentTime,
rae.is_edited
FROM FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
@ -61,7 +62,8 @@ SELECT
lae.i_cloud_id, lae.i_cloud_id,
lae.latitude, lae.latitude,
lae.longitude, lae.longitude,
lae.adjustment_time lae.adjustment_time,
0 as is_edited
FROM FROM
local_asset_entity lae local_asset_entity lae
WHERE NOT EXISTS ( WHERE NOT EXISTS (

View file

@ -44,6 +44,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
TextColumn get libraryId => text().nullable()(); TextColumn get libraryId => text().nullable()();
BoolColumn get isEdited => boolean().withDefault(const Constant(false))();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@ -66,5 +68,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
localId: localId, localId: localId,
stackId: stackId, stackId: stackId,
isEdited: isEdited,
); );
} }

View file

@ -45,5 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
height: height, height: height,
width: width, width: width,
orientation: orientation, orientation: orientation,
isEdited: false,
); );
} }

View file

@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 16; int get schemaVersion => 17;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -201,6 +201,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v16.idxLocalAssetCloudId); await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity); await m.createTable(v16.remoteAssetCloudIdEntity);
}, },
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
), ),
); );

View file

@ -200,6 +200,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
libraryId: Value(asset.libraryId), libraryId: Value(asset.libraryId),
width: Value(asset.width), width: Value(asset.width),
height: Value(asset.height), height: Value(asset.height),
isEdited: Value(asset.isEdited),
); );
batch.insert( batch.insert(

View file

@ -70,6 +70,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
durationInSeconds: row.durationInSeconds, durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId, livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId, stackId: row.stackId,
isEdited: row.isEdited,
) )
: LocalAsset( : LocalAsset(
id: row.localId!, id: row.localId!,
@ -88,6 +89,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
latitude: row.latitude, latitude: row.latitude,
longitude: row.longitude, longitude: row.longitude,
adjustmentTime: row.adjustmentTime, adjustmentTime: row.adjustmentTime,
isEdited: row.isEdited,
), ),
) )
.get(); .get();

View file

@ -118,6 +118,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
), ),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), _PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId), _PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]); ]);
} }

View file

@ -167,7 +167,7 @@ class _PlaceTile extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 80, width: 80,
height: 80, height: 80,
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover), child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""),
), ),
), ),
); );

View file

@ -14,14 +14,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -666,6 +667,8 @@ class _GridAlbumCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector( return GestureDetector(
onTap: () => onAlbumSelected(album), onTap: () => onAlbumSelected(album),
child: Card( child: Card(
@ -684,12 +687,22 @@ class _GridAlbumCard extends ConsumerWidget {
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)), borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: album.thumbnailAssetId != null child: FutureBuilder(
? Thumbnail.remote(remoteId: album.thumbnailAssetId!) future: albumThumbnailAsset,
: Container( builder: (context, snapshot) {
color: context.colorScheme.surfaceContainerHighest, if (snapshot.hasData && snapshot.data != null) {
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey), return Thumbnail.remote(
), remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
);
}
return Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
);
},
),
), ),
), ),
), ),

View file

@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class AlbumTile extends StatelessWidget { class AlbumTile extends ConsumerWidget {
const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected}); const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected});
final RemoteAlbum album; final RemoteAlbum album;
@ -14,7 +16,9 @@ class AlbumTile extends StatelessWidget {
final Function(RemoteAlbum)? onAlbumSelected; final Function(RemoteAlbum)? onAlbumSelected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return LargeLeadingTile( return LargeLeadingTile(
title: Text( title: Text(
album.name, album.name,
@ -29,23 +33,35 @@ class AlbumTile extends StatelessWidget {
), ),
onTap: () => onAlbumSelected?.call(album), onTap: () => onAlbumSelected?.call(album),
leadingPadding: const EdgeInsets.only(right: 16), leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null leading: FutureBuilder(
? ClipRRect( future: albumThumbnailAsset,
borderRadius: const BorderRadius.all(Radius.circular(15)), builder: (context, snapshot) {
child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), return snapshot.hasData && snapshot.data != null
) ? ClipRRect(
: SizedBox( borderRadius: const BorderRadius.all(Radius.circular(15)),
width: 80, child: SizedBox(
height: 80, width: 80,
child: Container( height: 80,
decoration: BoxDecoration( child: Thumbnail.remote(
color: context.colorScheme.surfaceContainer, remoteId: album.thumbnailAssetId!,
borderRadius: const BorderRadius.all(Radius.circular(16)), thumbhash: snapshot.data!.thumbHash ?? "",
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1), ),
), ),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), )
), : SizedBox(
), width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
);
},
),
); );
} }
} }

View file

@ -112,14 +112,17 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else { } else {
final String assetId; final String assetId;
final String thumbhash;
if (asset is LocalAsset && asset.hasRemote) { if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!; assetId = asset.remoteId!;
thumbhash = "";
} else if (asset is RemoteAsset) { } else if (asset is RemoteAsset) {
assetId = asset.id; assetId = asset.id;
thumbhash = asset.thumbHash ?? "";
} else { } else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
} }
provider = RemoteFullImageProvider(assetId: assetId); provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
} }
return provider; return provider;
@ -132,8 +135,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
} }
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
} }
bool _shouldUseLocalAsset(BaseAsset asset) => bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;

View file

@ -16,8 +16,9 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> { with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager(); static final cacheManager = RemoteThumbnailCacheManager();
final String assetId; final String assetId;
final String thumbhash;
RemoteThumbProvider({required this.assetId}); RemoteThumbProvider({required this.assetId, required this.thumbhash});
@override @override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) { Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -38,7 +39,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest( final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId), uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(), headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager, cacheManager: cacheManager,
); );
@ -49,22 +50,23 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is RemoteThumbProvider) { if (other is RemoteThumbProvider) {
return assetId == other.assetId; return assetId == other.assetId && thumbhash == other.thumbhash;
} }
return false; return false;
} }
@override @override
int get hashCode => assetId.hashCode; int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
} }
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider> class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> { with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager(); static final cacheManager = RemoteThumbnailCacheManager();
final String assetId; final String assetId;
final String thumbhash;
RemoteFullImageProvider({required this.assetId}); RemoteFullImageProvider({required this.assetId, required this.thumbhash});
@override @override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -75,7 +77,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)), initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId), DiagnosticsProperty<String>('Asset Id', key.assetId),
@ -94,7 +96,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest( final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview), uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers, headers: headers,
cacheManager: cacheManager, cacheManager: cacheManager,
); );
@ -115,12 +117,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) { if (other is RemoteFullImageProvider) {
return assetId == other.assetId; return assetId == other.assetId && thumbhash == other.thumbhash;
} }
return false; return false;
} }
@override @override
int get hashCode => assetId.hashCode; int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
} }

View file

@ -21,9 +21,14 @@ class Thumbnail extends StatefulWidget {
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key}); const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key}) Thumbnail.remote({
: imageProvider = RemoteThumbProvider(assetId: remoteId), required String remoteId,
thumbhashProvider = null; required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
Thumbnail.fromAsset({ Thumbnail.fromAsset({
required BaseAsset? asset, required BaseAsset? asset,

View file

@ -60,7 +60,11 @@ class DriftMemoryCard extends ConsumerWidget {
child: SizedBox( child: SizedBox(
width: 205, width: 205,
height: 200, height: 200,
child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover), child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
), ),
), ),
Positioned( Positioned(

View file

@ -69,6 +69,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
: AssetType.other, : AssetType.other,
createdAt: asset.fileCreatedAt, createdAt: asset.fileCreatedAt,
updatedAt: asset.updatedAt, updatedAt: asset.updatedAt,
isEdited: false,
); );
_gCastService.loadMedia(remoteAsset, reload); _gCastService.loadMedia(remoteAsset, reload);

View file

@ -144,6 +144,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_hidden', _handleOnAssetHidden); socket.on('on_asset_hidden', _handleOnAssetHidden);
} else { } else {
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
} }
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
@ -192,10 +193,12 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void stopListeningToBetaEvents() { void stopListeningToBetaEvents() {
state.socket?.off('AssetUploadReadyV1'); state.socket?.off('AssetUploadReadyV1');
state.socket?.off('AssetEditReadyV1');
} }
void startListeningToBetaEvents() { void startListeningToBetaEvents() {
state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
state.socket?.on('AssetEditReadyV1', _handleSyncAssetEditReady);
} }
void listenUploadEvent() { void listenUploadEvent() {
@ -315,6 +318,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchDebouncer.run(_processBatchedAssetUploadReady); _batchDebouncer.run(_processBatchedAssetUploadReady);
} }
void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data]));
}
void _processBatchedAssetUploadReady() { void _processBatchedAssetUploadReady() {
if (_batchedAssetUploadReady.isEmpty) { if (_batchedAssetUploadReady.isEmpty) {
return; return;

View file

@ -25,6 +25,7 @@ class FileMediaRepository {
type: AssetType.image, type: AssetType.image,
createdAt: entity.createDateTime, createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime, updatedAt: entity.modifiedDateTime,
isEdited: false,
); );
} }

View file

@ -50,8 +50,10 @@ String getThumbnailUrlForRemoteId(
final String id, { final String id, {
AssetMediaSize type = AssetMediaSize.thumbnail, AssetMediaSize type = AssetMediaSize.thumbnail,
bool edited = true, bool edited = true,
String? thumbhash,
}) { }) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited'; final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
} }
String getPlaybackUrlForRemoteId(final String id) { String getPlaybackUrlForRemoteId(final String id) {

View file

@ -49,7 +49,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
} }
case 'SyncAssetV1': case 'SyncAssetV1':
if (value is Map) { if (value is Map) {
addDefault(value, 'editCount', 0); addDefault(value, 'isEdited', false);
} }
case 'ServerFeaturesDto': case 'ServerFeaturesDto':
if (value is Map) { if (value is Map) {

Binary file not shown.

View file

@ -44,7 +44,7 @@ SyncAssetV1 _createAsset({
livePhotoVideoId: null, livePhotoVideoId: null,
stackId: null, stackId: null,
thumbhash: null, thumbhash: null,
editCount: 0, isEdited: false,
); );
} }

Binary file not shown.

Binary file not shown.

View file

@ -64,6 +64,7 @@ abstract final class LocalAssetStub {
type: AssetType.image, type: AssetType.image,
createdAt: DateTime(2025), createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2), updatedAt: DateTime(2025, 2),
isEdited: false,
); );
static final image2 = LocalAsset( static final image2 = LocalAsset(
@ -72,5 +73,6 @@ abstract final class LocalAssetStub {
type: AssetType.image, type: AssetType.image,
createdAt: DateTime(2000), createdAt: DateTime(2000),
updatedAt: DateTime(20021), updatedAt: DateTime(20021),
isEdited: false,
); );
} }

View file

@ -128,7 +128,7 @@ abstract final class SyncStreamStub {
visibility: AssetVisibility.timeline, visibility: AssetVisibility.timeline,
width: null, width: null,
height: null, height: null,
editCount: 0, isEdited: false,
), ),
ack: ack, ack: ack,
); );

View file

@ -194,6 +194,7 @@ void main() {
latitude: 37.7749, latitude: 37.7749,
longitude: -122.4194, longitude: -122.4194,
adjustmentTime: DateTime(2026, 1, 2), adjustmentTime: DateTime(2026, 1, 2),
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
@ -242,6 +243,7 @@ void main() {
cloudId: 'cloud-id-123', cloudId: 'cloud-id-123',
latitude: 37.7749, latitude: 37.7749,
longitude: -122.4194, longitude: -122.4194,
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
@ -279,6 +281,7 @@ void main() {
createdAt: DateTime(2025, 1, 1), createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2), updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId cloudId: null, // No cloudId
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();
@ -320,6 +323,7 @@ void main() {
cloudId: 'cloud-id-livephoto', cloudId: 'cloud-id-livephoto',
latitude: 37.7749, latitude: 37.7749,
longitude: -122.4194, longitude: -122.4194,
isEdited: false,
); );
final mockEntity = MockAssetEntity(); final mockEntity = MockAssetEntity();

View file

@ -131,6 +131,7 @@ abstract final class TestUtils {
isFavorite: false, isFavorite: false,
width: width, width: width,
height: height, height: height,
isEdited: false,
); );
} }
@ -154,6 +155,7 @@ abstract final class TestUtils {
width: width, width: width,
height: height, height: height,
orientation: orientation, orientation: orientation,
isEdited: false,
); );
} }
} }

View file

@ -27,6 +27,7 @@ class MediumFactory {
type: type ?? AssetType.image, type: type ?? AssetType.image,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
isEdited: false,
); );
} }

View file

@ -23,6 +23,7 @@ LocalAsset createLocalAsset({
createdAt: createdAt ?? DateTime.now(), createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(),
isFavorite: isFavorite, isFavorite: isFavorite,
isEdited: false,
); );
} }
@ -45,6 +46,7 @@ RemoteAsset createRemoteAsset({
createdAt: createdAt ?? DateTime.now(), createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(),
isFavorite: isFavorite, isFavorite: isFavorite,
isEdited: false,
); );
} }

View file

@ -21291,9 +21291,6 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"editCount": {
"type": "integer"
},
"fileCreatedAt": { "fileCreatedAt": {
"format": "date-time", "format": "date-time",
"nullable": true, "nullable": true,
@ -21311,6 +21308,9 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"isEdited": {
"type": "boolean"
},
"isFavorite": { "isFavorite": {
"type": "boolean" "type": "boolean"
}, },
@ -21364,11 +21364,11 @@
"checksum", "checksum",
"deletedAt", "deletedAt",
"duration", "duration",
"editCount",
"fileCreatedAt", "fileCreatedAt",
"fileModifiedAt", "fileModifiedAt",
"height", "height",
"id", "id",
"isEdited",
"isFavorite", "isFavorite",
"libraryId", "libraryId",
"livePhotoVideoId", "livePhotoVideoId",

View file

@ -395,7 +395,7 @@ export const columns = {
'asset.libraryId', 'asset.libraryId',
'asset.width', 'asset.width',
'asset.height', 'asset.height',
'asset.editCount', 'asset.isEdited',
], ],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],

View file

@ -139,7 +139,7 @@ export type MapAsset = {
type: AssetType; type: AssetType;
width: number | null; width: number | null;
height: number | null; height: number | null;
editCount: number; isEdited: boolean;
}; };
export class AssetStackResponseDto { export class AssetStackResponseDto {
@ -248,6 +248,6 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
resized: true, resized: true,
width: entity.width, width: entity.width,
height: entity.height, height: entity.height,
isEdited: entity.editCount > 0, isEdited: entity.isEdited,
}; };
} }

View file

@ -121,8 +121,8 @@ export class SyncAssetV1 {
width!: number | null; width!: number | null;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
height!: number | null; height!: number | null;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'boolean' })
editCount!: number; isEdited!: boolean;
} }
@ExtraModel() @ExtraModel()

View file

@ -71,7 +71,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount", "asset"."isEdited",
"album_asset"."updateId" "album_asset"."updateId"
from from
"album_asset" as "album_asset" "album_asset" as "album_asset"
@ -104,7 +104,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount", "asset"."isEdited",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
@ -143,7 +143,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount" "asset"."isEdited"
from from
"album_asset" as "album_asset" "album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId" inner join "asset" on "asset"."id" = "album_asset"."assetId"
@ -459,7 +459,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount", "asset"."isEdited",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
@ -755,7 +755,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount", "asset"."isEdited",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
@ -807,7 +807,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount", "asset"."isEdited",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"

View file

@ -37,7 +37,7 @@ export interface ClientEventMap {
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent]; AppRestartV1: [AppRestartEvent];
AssetEditReadyV1: [{ assetId: string }]; AssetEditReadyV1: [{ asset: SyncAssetV1 }];
} }
export type AuthFn = (client: Socket) => Promise<AuthDto>; export type AuthFn = (client: Socket) => Promise<AuthDto>;

View file

@ -263,8 +263,9 @@ export const asset_edit_insert = registerFunction({
body: ` body: `
BEGIN BEGIN
UPDATE asset UPDATE asset
SET "editCount" = "editCount" + 1 SET "isEdited" = true
WHERE "id" = NEW."assetId"; FROM inserted_edit
WHERE asset.id = inserted_edit."assetId" AND NOT asset."isEdited";
RETURN NULL; RETURN NULL;
END END
`, `,
@ -277,8 +278,10 @@ export const asset_edit_delete = registerFunction({
body: ` body: `
BEGIN BEGIN
UPDATE asset UPDATE asset
SET "editCount" = "editCount" - 1 SET "isEdited" = false
WHERE "id" = OLD."assetId"; FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL; RETURN NULL;
END END
`, `,

View file

@ -0,0 +1,89 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_edit_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE asset
SET "isEdited" = true
FROM inserted_edit
WHERE asset.id = inserted_edit."assetId" AND NOT asset."isEdited";
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$$;`.execute(db);
await sql`ALTER TABLE "asset" ADD "isEdited" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_edit_delete"
AFTER DELETE ON "asset_edit"
REFERENCING OLD TABLE AS "deleted_edit"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION asset_edit_delete();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_edit_insert"
AFTER INSERT ON "asset_edit"
REFERENCING NEW TABLE AS "inserted_edit"
FOR EACH STATEMENT
EXECUTE FUNCTION asset_edit_insert();`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "editCount";`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_insert","sql":"CREATE OR REPLACE FUNCTION asset_edit_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = true\\n FROM inserted_edit\\n WHERE asset.id = inserted_edit.\\"assetId\\" AND NOT asset.\\"isEdited\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_insert';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"asset_edit_delete","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_delete\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"deleted_edit\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_delete();"}'::jsonb WHERE "name" = 'trigger_asset_edit_delete';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"asset_edit_insert","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_insert\\"\\n AFTER INSERT ON \\"asset_edit\\"\\n REFERENCING NEW TABLE AS \\"inserted_edit\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION asset_edit_insert();"}'::jsonb WHERE "name" = 'trigger_asset_edit_insert';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE asset
SET "editCount" = "editCount" + 1
WHERE "id" = NEW."assetId";
RETURN NULL;
END
$function$
`.execute(db);
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE asset
SET "editCount" = "editCount" - 1
WHERE "id" = OLD."assetId";
RETURN NULL;
END
$function$
`.execute(db);
await sql`ALTER TABLE "asset" ADD "editCount" integer NOT NULL DEFAULT 0;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_edit_delete"
AFTER DELETE ON "asset_edit"
REFERENCING OLD TABLE AS "old"
FOR EACH ROW
WHEN ((pg_trigger_depth() = 0))
EXECUTE FUNCTION asset_edit_delete();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_edit_insert"
AFTER INSERT ON "asset_edit"
FOR EACH ROW
EXECUTE FUNCTION asset_edit_insert();`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "isEdited";`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"editCount\\" = \\"editCount\\" + 1\\n WHERE \\"id\\" = NEW.\\"assetId\\";\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_insert","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_insert';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"editCount\\" = \\"editCount\\" - 1\\n WHERE \\"id\\" = OLD.\\"assetId\\";\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_delete\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_delete();","name":"asset_edit_delete","type":"trigger"}'::jsonb WHERE "name" = 'trigger_asset_edit_delete';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_insert\\"\\n AFTER INSERT ON \\"asset_edit\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION asset_edit_insert();","name":"asset_edit_insert","type":"trigger"}'::jsonb WHERE "name" = 'trigger_asset_edit_insert';`.execute(db);
}

View file

@ -12,11 +12,11 @@ import {
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('asset_edit') @Table('asset_edit')
@AfterInsertTrigger({ scope: 'row', function: asset_edit_insert }) @AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' })
@AfterDeleteTrigger({ @AfterDeleteTrigger({
scope: 'row', scope: 'statement',
function: asset_edit_delete, function: asset_edit_delete,
referencingOldTableAs: 'old', referencingOldTableAs: 'deleted_edit',
when: 'pg_trigger_depth() = 0', when: 'pg_trigger_depth() = 0',
}) })
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> { export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {

View file

@ -144,6 +144,6 @@ export class AssetTable {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
height!: number | null; height!: number | null;
@Column({ type: 'integer', default: 0 }) @Column({ type: 'boolean', default: false })
editCount!: Generated<number>; isEdited!: Generated<boolean>;
} }

View file

@ -100,7 +100,29 @@ export class JobService extends BaseService {
const asset = await this.assetRepository.getById(item.data.id); const asset = await this.assetRepository.getById(item.data.id);
if (asset) { if (asset) {
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { assetId: item.data.id }); this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
asset: {
id: asset.id,
ownerId: asset.ownerId,
originalFileName: asset.originalFileName,
thumbhash: asset.thumbhash ? hexOrBufferToBase64(asset.thumbhash) : null,
checksum: hexOrBufferToBase64(asset.checksum),
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
duration: asset.duration,
type: asset.type,
deletedAt: asset.deletedAt,
isFavorite: asset.isFavorite,
visibility: asset.visibility,
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
isEdited: asset.isEdited,
},
});
} }
break; break;
@ -153,7 +175,7 @@ export class JobService extends BaseService {
libraryId: asset.libraryId, libraryId: asset.libraryId,
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
editCount: asset.editCount, isEdited: asset.isEdited,
}, },
exif: { exif: {
assetId: exif.assetId, assetId: exif.assetId,

View file

@ -86,7 +86,7 @@ export const assetStub = {
make: 'FUJIFILM', make: 'FUJIFILM',
model: 'X-T50', model: 'X-T50',
lensModel: 'XF27mm F2.8 R WR', lensModel: 'XF27mm F2.8 R WR',
editCount: 0, isEdited: false,
...asset, ...asset,
}), }),
noResizePath: Object.freeze({ noResizePath: Object.freeze({
@ -126,7 +126,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
noWebpPath: Object.freeze({ noWebpPath: Object.freeze({
@ -168,7 +168,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
noThumbhash: Object.freeze({ noThumbhash: Object.freeze({
@ -207,7 +207,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
primaryImage: Object.freeze({ primaryImage: Object.freeze({
@ -256,7 +256,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
image: Object.freeze({ image: Object.freeze({
@ -303,7 +303,7 @@ export const assetStub = {
width: null, width: null,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
trashed: Object.freeze({ trashed: Object.freeze({
@ -347,7 +347,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
trashedOffline: Object.freeze({ trashedOffline: Object.freeze({
@ -391,7 +391,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
archived: Object.freeze({ archived: Object.freeze({
id: 'asset-id', id: 'asset-id',
@ -434,7 +434,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
external: Object.freeze({ external: Object.freeze({
@ -477,7 +477,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
image1: Object.freeze({ image1: Object.freeze({
@ -520,7 +520,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
imageFrom2015: Object.freeze({ imageFrom2015: Object.freeze({
@ -562,7 +562,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
video: Object.freeze({ video: Object.freeze({
@ -606,7 +606,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
livePhotoMotionAsset: Object.freeze({ livePhotoMotionAsset: Object.freeze({
@ -627,7 +627,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
editCount: 0, isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
livePhotoStillAsset: Object.freeze({ livePhotoStillAsset: Object.freeze({
@ -649,7 +649,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
editCount: 0, isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
livePhotoWithOriginalFileName: Object.freeze({ livePhotoWithOriginalFileName: Object.freeze({
@ -673,7 +673,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
editCount: 0, isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
withLocation: Object.freeze({ withLocation: Object.freeze({
@ -721,7 +721,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
sidecar: Object.freeze({ sidecar: Object.freeze({
@ -760,7 +760,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
sidecarWithoutExt: Object.freeze({ sidecarWithoutExt: Object.freeze({
@ -796,7 +796,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
hasEncodedVideo: Object.freeze({ hasEncodedVideo: Object.freeze({
@ -839,7 +839,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
hasFileExtension: Object.freeze({ hasFileExtension: Object.freeze({
@ -879,7 +879,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
imageDng: Object.freeze({ imageDng: Object.freeze({
@ -923,7 +923,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
imageHif: Object.freeze({ imageHif: Object.freeze({
@ -967,7 +967,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
panoramaTif: Object.freeze({ panoramaTif: Object.freeze({
@ -1068,7 +1068,7 @@ export const assetStub = {
}, },
}, },
] as AssetEditActionItem[], ] as AssetEditActionItem[],
editCount: 1, isEdited: true,
}), }),
withoutEdits: Object.freeze({ withoutEdits: Object.freeze({
@ -1116,6 +1116,6 @@ export const assetStub = {
width: 2160, width: 2160,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
edits: [], edits: [],
editCount: 0, isEdited: false,
}), }),
}; };

View file

@ -159,7 +159,7 @@ export const sharedLinkStub = {
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
width: 500, width: 500,
height: 500, height: 500,
editCount: 0, isEdited: false,
}, },
], ],
albumId: null, albumId: null,

View file

@ -537,7 +537,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
fileModifiedAt: now, fileModifiedAt: now,
localDateTime: now, localDateTime: now,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
editCount: 0, isEdited: false,
}; };
return { return {

View file

@ -24,32 +24,32 @@ beforeAll(async () => {
describe(AssetEditRepository.name, () => { describe(AssetEditRepository.name, () => {
describe('replaceAll', () => { describe('replaceAll', () => {
it('should increment editCount on insert', async () => { it('should set isEdited on insert', async () => {
const { ctx, sut } = setup(); const { ctx, sut } = setup();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 0 }); ).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [ await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
]); ]);
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 1 }); ).resolves.toEqual({ isEdited: true });
}); });
it('should increment editCount when inserting multiple edits', async () => { it('should set isEdited when inserting multiple edits', async () => {
const { ctx, sut } = setup(); const { ctx, sut } = setup();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 0 }); ).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [ await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
@ -58,18 +58,18 @@ describe(AssetEditRepository.name, () => {
]); ]);
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 3 }); ).resolves.toEqual({ isEdited: true });
}); });
it('should decrement editCount', async () => { it('should keep isEdited when removing some edits', async () => {
const { ctx, sut } = setup(); const { ctx, sut } = setup();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 0 }); ).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [ await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
@ -77,23 +77,27 @@ describe(AssetEditRepository.name, () => {
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }, { action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]); ]);
await expect(
ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ isEdited: true });
await sut.replaceAll(asset.id, [ await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
]); ]);
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 1 }); ).resolves.toEqual({ isEdited: true });
}); });
it('should set editCount to 0 if all edits are deleted', async () => { it('should set isEdited to false if all edits are deleted', async () => {
const { ctx, sut } = setup(); const { ctx, sut } = setup();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 0 }); ).resolves.toEqual({ isEdited: false });
await sut.replaceAll(asset.id, [ await sut.replaceAll(asset.id, [
{ action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } },
@ -104,8 +108,8 @@ describe(AssetEditRepository.name, () => {
await sut.replaceAll(asset.id, []); await sut.replaceAll(asset.id, []);
await expect( await expect(
ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(),
).resolves.toEqual({ editCount: 0 }); ).resolves.toEqual({ isEdited: false });
}); });
}); });
}); });

View file

@ -83,7 +83,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
libraryId: asset.libraryId, libraryId: asset.libraryId,
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
editCount: asset.editCount, isEdited: asset.isEdited,
}, },
type: SyncEntityType.AlbumAssetCreateV1, type: SyncEntityType.AlbumAssetCreateV1,
}, },

View file

@ -64,7 +64,7 @@ describe(SyncEntityType.AssetV1, () => {
libraryId: asset.libraryId, libraryId: asset.libraryId,
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
editCount: asset.editCount, isEdited: asset.isEdited,
}, },
type: 'AssetV1', type: 'AssetV1',
}, },

View file

@ -63,7 +63,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
type: asset.type, type: asset.type,
visibility: asset.visibility, visibility: asset.visibility,
duration: asset.duration, duration: asset.duration,
editCount: asset.editCount, isEdited: asset.isEdited,
stackId: null, stackId: null,
livePhotoVideoId: null, livePhotoVideoId: null,
libraryId: asset.libraryId, libraryId: asset.libraryId,

View file

@ -253,7 +253,7 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
width: null, width: null,
height: null, height: null,
editCount: 0, isEdited: false,
...asset, ...asset,
}); });

View file

@ -7,6 +7,7 @@
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import EditAction from '$lib/components/asset-viewer/actions/edit-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
@ -19,6 +20,7 @@
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { ProjectionType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
@ -71,7 +73,7 @@
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void; onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void; onPlaySlideshow: () => void;
// onEdit: () => void; onEdit: () => void;
onClose?: () => void; onClose?: () => void;
playOriginalVideo: boolean; playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void; setPlayOriginalVideo: (value: boolean) => void;
@ -91,7 +93,7 @@
onRunJob, onRunJob,
onPlaySlideshow, onPlaySlideshow,
onClose, onClose,
// onEdit, onEdit,
playOriginalVideo = false, playOriginalVideo = false,
setPlayOriginalVideo, setPlayOriginalVideo,
}: Props = $props(); }: Props = $props();
@ -126,17 +128,17 @@
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
// TODO: Enable when edits are ready for release // TODO: Enable when edits are ready for release
// let showEditorButton = $derived( let showEditorButton = $derived(
// isOwner && isOwner &&
// asset.type === AssetTypeEnum.Image && asset.type === AssetTypeEnum.Image &&
// !( !(
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
// ) && ) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) && !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
// !asset.livePhotoVideoId, !asset.livePhotoVideoId,
// ); );
</script> </script>
<CommandPaletteDefaultProvider <CommandPaletteDefaultProvider
@ -189,9 +191,9 @@
<RatingAction {asset} {onAction} /> <RatingAction {asset} {onAction} />
{/if} {/if}
<!-- {#if showEditorButton} {#if showEditorButton}
<EditAction onAction={onEdit} /> <EditAction onAction={onEdit} />
{/if} --> {/if}
{#if isOwner} {#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} /> <DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />

View file

@ -254,12 +254,12 @@
}); });
}; };
// const showEditor = () => { const showEditor = () => {
// if (assetViewerManager.isShowActivityPanel) { if (assetViewerManager.isShowActivityPanel) {
// assetViewerManager.isShowActivityPanel = false; assetViewerManager.isShowActivityPanel = false;
// } }
// isShowEditor = !isShowEditor; isShowEditor = !isShowEditor;
// }; };
const handleRunJob = async (name: AssetJobName) => { const handleRunJob = async (name: AssetJobName) => {
try { try {
@ -466,6 +466,7 @@
preAction={handlePreAction} preAction={handlePreAction}
onAction={handleAction} onAction={handleAction}
{onUndoDelete} {onUndoDelete}
onEdit={showEditor}
onRunJob={handleRunJob} onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined} onClose={onClose ? () => onClose(asset) : undefined}

View file

@ -115,7 +115,7 @@ export class EditManager {
// Setup the websocket listener before sending the edit request // Setup the websocket listener before sending the edit request
const editCompleted = waitForWebsocketEvent( const editCompleted = waitForWebsocketEvent(
'AssetEditReadyV1', 'AssetEditReadyV1',
(event) => event.assetId === this.currentAsset!.id, (event) => event.asset.id === this.currentAsset!.id,
10_000, 10_000,
); );

View file

@ -31,7 +31,7 @@ export interface Events {
on_notification: (notification: NotificationDto) => void; on_notification: (notification: NotificationDto) => void;
AppRestartV1: (event: AppRestartEvent) => void; AppRestartV1: (event: AppRestartEvent) => void;
AssetEditReadyV1: (data: { assetId: string }) => void; AssetEditReadyV1: (data: { asset: { id: string } }) => void;
} }
const websocket: Socket<Events> = io({ const websocket: Socket<Events> = io({