mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01:00
Merge branch 'main' of github.com:immich-app/immich
This commit is contained in:
commit
833c099025
33 changed files with 855 additions and 483 deletions
|
@ -50,6 +50,7 @@ void main() async {
|
||||||
await initApp();
|
await initApp();
|
||||||
await migrateHiveToStoreIfNecessary();
|
await migrateHiveToStoreIfNecessary();
|
||||||
await migrateJsonCacheIfNecessary();
|
await migrateJsonCacheIfNecessary();
|
||||||
|
await migrateDatabaseIfNeeded(db);
|
||||||
runApp(getMainWidget(db));
|
runApp(getMainWidget(db));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,7 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||||
// Add the owner name to the subtitle
|
// Add the owner name to the subtitle
|
||||||
String? owner;
|
String? owner;
|
||||||
if (showOwner) {
|
if (showOwner) {
|
||||||
if (album.ownerId == Store.get(StoreKey.userRemoteId)) {
|
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
|
||||||
owner = 'album_thumbnail_owned'.tr();
|
owner = 'album_thumbnail_owned'.tr();
|
||||||
} else if (album.ownerName != null) {
|
} else if (album.ownerName != null) {
|
||||||
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
|
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
|
||||||
|
|
|
@ -17,7 +17,7 @@ class SharingPage extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
|
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
final userId = store.Store.get(store.StoreKey.userRemoteId);
|
final userId = store.Store.get(store.StoreKey.currentUser).id;
|
||||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|
|
@ -17,10 +17,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
final List<Asset> assets;
|
final List<Asset> assets;
|
||||||
final RenderList? renderList;
|
final RenderList? renderList;
|
||||||
|
final Future<void> Function()? onRefresh;
|
||||||
|
|
||||||
const ImmichAssetGrid({
|
const ImmichAssetGrid({
|
||||||
super.key,
|
super.key,
|
||||||
required this.assets,
|
required this.assets,
|
||||||
|
this.onRefresh,
|
||||||
this.renderList,
|
this.renderList,
|
||||||
this.assetsPerRow,
|
this.assetsPerRow,
|
||||||
this.showStorageIndicator,
|
this.showStorageIndicator,
|
||||||
|
@ -62,11 +64,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
enabled: enableHeroAnimations.value,
|
enabled: enableHeroAnimations.value,
|
||||||
child: ImmichAssetGridView(
|
child: ImmichAssetGridView(
|
||||||
allAssets: assets,
|
allAssets: assets,
|
||||||
assetsPerRow: assetsPerRow
|
onRefresh: onRefresh,
|
||||||
?? settings.getSetting(AppSettingsEnum.tilesPerRow),
|
assetsPerRow: assetsPerRow ??
|
||||||
|
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
listener: listener,
|
listener: listener,
|
||||||
showStorageIndicator: showStorageIndicator
|
showStorageIndicator: showStorageIndicator ??
|
||||||
?? settings.getSetting(AppSettingsEnum.storageIndicator),
|
settings.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
renderList: renderList!,
|
renderList: renderList!,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
selectionActive: selectionActive,
|
selectionActive: selectionActive,
|
||||||
|
@ -76,26 +79,25 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderListFuture.when(
|
return renderListFuture.when(
|
||||||
data: (renderList) =>
|
data: (renderList) => WillPopScope(
|
||||||
WillPopScope(
|
onWillPop: onWillPop,
|
||||||
onWillPop: onWillPop,
|
child: HeroMode(
|
||||||
child: HeroMode(
|
enabled: enableHeroAnimations.value,
|
||||||
enabled: enableHeroAnimations.value,
|
child: ImmichAssetGridView(
|
||||||
child: ImmichAssetGridView(
|
allAssets: assets,
|
||||||
allAssets: assets,
|
onRefresh: onRefresh,
|
||||||
assetsPerRow: assetsPerRow
|
assetsPerRow: assetsPerRow ??
|
||||||
?? settings.getSetting(AppSettingsEnum.tilesPerRow),
|
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
listener: listener,
|
listener: listener,
|
||||||
showStorageIndicator: showStorageIndicator
|
showStorageIndicator: showStorageIndicator ??
|
||||||
?? settings.getSetting(AppSettingsEnum.storageIndicator),
|
settings.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
renderList: renderList,
|
renderList: renderList,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
selectionActive: selectionActive,
|
selectionActive: selectionActive,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (err, stack) =>
|
),
|
||||||
Center(child: Text("$err")),
|
error: (err, stack) => Center(child: Text("$err")),
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
child: ImmichLoadingIndicator(),
|
child: ImmichLoadingIndicator(),
|
||||||
),
|
),
|
||||||
|
|
|
@ -199,21 +199,23 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||||
addRepaintBoundaries: true,
|
addRepaintBoundaries: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!useDragScrolling) {
|
final child = useDragScrolling
|
||||||
return listWidget;
|
? DraggableScrollbar.semicircle(
|
||||||
}
|
scrollStateListener: dragScrolling,
|
||||||
|
itemPositionsListener: _itemPositionsListener,
|
||||||
|
controller: _itemScrollController,
|
||||||
|
backgroundColor: Theme.of(context).hintColor,
|
||||||
|
labelTextBuilder: _labelBuilder,
|
||||||
|
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||||
|
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||||
|
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||||
|
child: listWidget,
|
||||||
|
)
|
||||||
|
: listWidget;
|
||||||
|
|
||||||
return DraggableScrollbar.semicircle(
|
return widget.onRefresh == null
|
||||||
scrollStateListener: dragScrolling,
|
? child
|
||||||
itemPositionsListener: _itemPositionsListener,
|
: RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
|
||||||
controller: _itemScrollController,
|
|
||||||
backgroundColor: Theme.of(context).hintColor,
|
|
||||||
labelTextBuilder: _labelBuilder,
|
|
||||||
labelConstraints: const BoxConstraints(maxHeight: 28),
|
|
||||||
scrollbarAnimationDuration: const Duration(seconds: 1),
|
|
||||||
scrollbarTimeToFade: const Duration(seconds: 4),
|
|
||||||
child: listWidget,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -248,7 +250,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToTop() {
|
void _scrollToTop() {
|
||||||
// for some reason, this is necessary as well in order
|
// for some reason, this is necessary as well in order
|
||||||
// to correctly reposition the drag thumb scroll bar
|
// to correctly reposition the drag thumb scroll bar
|
||||||
_itemScrollController.jumpTo(
|
_itemScrollController.jumpTo(
|
||||||
index: 0,
|
index: 0,
|
||||||
|
@ -281,6 +283,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||||
final ImmichAssetGridSelectionListener? listener;
|
final ImmichAssetGridSelectionListener? listener;
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
final List<Asset> allAssets;
|
final List<Asset> allAssets;
|
||||||
|
final Future<void> Function()? onRefresh;
|
||||||
|
|
||||||
const ImmichAssetGridView({
|
const ImmichAssetGridView({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -291,6 +294,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||||
this.listener,
|
this.listener,
|
||||||
this.margin = 5.0,
|
this.margin = 5.0,
|
||||||
this.selectionActive = false,
|
this.selectionActive = false,
|
||||||
|
this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -43,6 +43,7 @@ class HomePage extends HookConsumerWidget {
|
||||||
final albumService = ref.watch(albumServiceProvider);
|
final albumService = ref.watch(albumServiceProvider);
|
||||||
|
|
||||||
final tipOneOpacity = useState(0.0);
|
final tipOneOpacity = useState(0.0);
|
||||||
|
final refreshCount = useState(0);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
|
@ -182,6 +183,22 @@ class HomePage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshAssets() async {
|
||||||
|
debugPrint("refreshCount.value ${refreshCount.value}");
|
||||||
|
final fullRefresh = refreshCount.value > 0;
|
||||||
|
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||||
|
if (fullRefresh) {
|
||||||
|
// refresh was forced: user requested another refresh within 2 seconds
|
||||||
|
refreshCount.value = 0;
|
||||||
|
} else {
|
||||||
|
refreshCount.value++;
|
||||||
|
// set counter back to 0 if user does not request refresh again
|
||||||
|
Timer(const Duration(seconds: 2), () {
|
||||||
|
refreshCount.value = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildLoadingIndicator() {
|
buildLoadingIndicator() {
|
||||||
Timer(const Duration(seconds: 2), () {
|
Timer(const Duration(seconds: 2), () {
|
||||||
tipOneOpacity.value = 1;
|
tipOneOpacity.value = 1;
|
||||||
|
@ -241,6 +258,7 @@ class HomePage extends HookConsumerWidget {
|
||||||
.getSetting(AppSettingsEnum.storageIndicator),
|
.getSetting(AppSettingsEnum.storageIndicator),
|
||||||
listener: selectionListener,
|
listener: selectionListener,
|
||||||
selectionActive: selectionEnabledHook.value,
|
selectionActive: selectionEnabledHook.value,
|
||||||
|
onRefresh: refreshAssets,
|
||||||
),
|
),
|
||||||
if (selectionEnabledHook.value)
|
if (selectionEnabledHook.value)
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
|
|
@ -78,7 +78,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_apiService.authenticationApi.logout(),
|
_apiService.authenticationApi.logout(),
|
||||||
Store.delete(StoreKey.assetETag),
|
Store.delete(StoreKey.assetETag),
|
||||||
Store.delete(StoreKey.userRemoteId),
|
|
||||||
Store.delete(StoreKey.currentUser),
|
Store.delete(StoreKey.currentUser),
|
||||||
Store.delete(StoreKey.accessToken),
|
Store.delete(StoreKey.accessToken),
|
||||||
]);
|
]);
|
||||||
|
@ -133,7 +132,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||||
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
|
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
|
||||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
|
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
|
||||||
Store.put(StoreKey.userRemoteId, userResponseDto.id);
|
|
||||||
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
|
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
|
||||||
Store.put(StoreKey.serverUrl, serverUrl);
|
Store.put(StoreKey.serverUrl, serverUrl);
|
||||||
Store.put(StoreKey.accessToken, accessToken);
|
Store.put(StoreKey.accessToken, accessToken);
|
||||||
|
|
|
@ -15,11 +15,11 @@ class Asset {
|
||||||
Asset.remote(AssetResponseDto remote)
|
Asset.remote(AssetResponseDto remote)
|
||||||
: remoteId = remote.id,
|
: remoteId = remote.id,
|
||||||
isLocal = false,
|
isLocal = false,
|
||||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
|
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
|
||||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
|
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
|
||||||
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
|
updatedAt = DateTime.parse(remote.updatedAt),
|
||||||
// use -1 as fallback duration (to not mix it up with non-video assets correctly having duration=0)
|
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? -1,
|
type = remote.type.toAssetType(),
|
||||||
fileName = p.basename(remote.originalPath),
|
fileName = p.basename(remote.originalPath),
|
||||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||||
|
@ -35,15 +35,16 @@ class Asset {
|
||||||
: localId = local.id,
|
: localId = local.id,
|
||||||
isLocal = true,
|
isLocal = true,
|
||||||
durationInSeconds = local.duration,
|
durationInSeconds = local.duration,
|
||||||
|
type = AssetType.values[local.typeInt],
|
||||||
height = local.height,
|
height = local.height,
|
||||||
width = local.width,
|
width = local.width,
|
||||||
fileName = local.title!,
|
fileName = local.title!,
|
||||||
deviceId = Store.get(StoreKey.deviceIdHash),
|
deviceId = Store.get(StoreKey.deviceIdHash),
|
||||||
ownerId = Store.get(StoreKey.currentUser).isarId,
|
ownerId = Store.get(StoreKey.currentUser).isarId,
|
||||||
fileModifiedAt = local.modifiedDateTime.toUtc(),
|
fileModifiedAt = local.modifiedDateTime,
|
||||||
updatedAt = local.modifiedDateTime.toUtc(),
|
updatedAt = local.modifiedDateTime,
|
||||||
isFavorite = local.isFavorite,
|
isFavorite = local.isFavorite,
|
||||||
fileCreatedAt = local.createDateTime.toUtc() {
|
fileCreatedAt = local.createDateTime {
|
||||||
if (fileCreatedAt.year == 1970) {
|
if (fileCreatedAt.year == 1970) {
|
||||||
fileCreatedAt = fileModifiedAt;
|
fileCreatedAt = fileModifiedAt;
|
||||||
}
|
}
|
||||||
|
@ -61,6 +62,7 @@ class Asset {
|
||||||
required this.fileModifiedAt,
|
required this.fileModifiedAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
required this.durationInSeconds,
|
required this.durationInSeconds,
|
||||||
|
required this.type,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
required this.fileName,
|
required this.fileName,
|
||||||
|
@ -77,10 +79,10 @@ class Asset {
|
||||||
AssetEntity? get local {
|
AssetEntity? get local {
|
||||||
if (isLocal && _local == null) {
|
if (isLocal && _local == null) {
|
||||||
_local = AssetEntity(
|
_local = AssetEntity(
|
||||||
id: localId.toString(),
|
id: localId,
|
||||||
typeInt: isImage ? 1 : 2,
|
typeInt: isImage ? 1 : 2,
|
||||||
width: width!,
|
width: width ?? 0,
|
||||||
height: height!,
|
height: height ?? 0,
|
||||||
duration: durationInSeconds,
|
duration: durationInSeconds,
|
||||||
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
@ -96,7 +98,7 @@ class Asset {
|
||||||
String? remoteId;
|
String? remoteId;
|
||||||
|
|
||||||
@Index(
|
@Index(
|
||||||
unique: true,
|
unique: false,
|
||||||
replace: false,
|
replace: false,
|
||||||
type: IndexType.hash,
|
type: IndexType.hash,
|
||||||
composite: [CompositeIndex('deviceId')],
|
composite: [CompositeIndex('deviceId')],
|
||||||
|
@ -115,6 +117,9 @@ class Asset {
|
||||||
|
|
||||||
int durationInSeconds;
|
int durationInSeconds;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.ordinal)
|
||||||
|
AssetType type;
|
||||||
|
|
||||||
short? width;
|
short? width;
|
||||||
|
|
||||||
short? height;
|
short? height;
|
||||||
|
@ -140,7 +145,7 @@ class Asset {
|
||||||
bool get isRemote => remoteId != null;
|
bool get isRemote => remoteId != null;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
bool get isImage => durationInSeconds == 0;
|
bool get isImage => type == AssetType.image;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
Duration get duration => Duration(seconds: durationInSeconds);
|
Duration get duration => Duration(seconds: durationInSeconds);
|
||||||
|
@ -148,12 +153,43 @@ class Asset {
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
if (other is! Asset) return false;
|
||||||
return id == other.id;
|
return id == other.id &&
|
||||||
|
remoteId == other.remoteId &&
|
||||||
|
localId == other.localId &&
|
||||||
|
deviceId == other.deviceId &&
|
||||||
|
ownerId == other.ownerId &&
|
||||||
|
fileCreatedAt == other.fileCreatedAt &&
|
||||||
|
fileModifiedAt == other.fileModifiedAt &&
|
||||||
|
updatedAt == other.updatedAt &&
|
||||||
|
durationInSeconds == other.durationInSeconds &&
|
||||||
|
type == other.type &&
|
||||||
|
width == other.width &&
|
||||||
|
height == other.height &&
|
||||||
|
fileName == other.fileName &&
|
||||||
|
livePhotoVideoId == other.livePhotoVideoId &&
|
||||||
|
isFavorite == other.isFavorite &&
|
||||||
|
isLocal == other.isLocal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ignore
|
@ignore
|
||||||
int get hashCode => id.hashCode;
|
int get hashCode =>
|
||||||
|
id.hashCode ^
|
||||||
|
remoteId.hashCode ^
|
||||||
|
localId.hashCode ^
|
||||||
|
deviceId.hashCode ^
|
||||||
|
ownerId.hashCode ^
|
||||||
|
fileCreatedAt.hashCode ^
|
||||||
|
fileModifiedAt.hashCode ^
|
||||||
|
updatedAt.hashCode ^
|
||||||
|
durationInSeconds.hashCode ^
|
||||||
|
type.hashCode ^
|
||||||
|
width.hashCode ^
|
||||||
|
height.hashCode ^
|
||||||
|
fileName.hashCode ^
|
||||||
|
livePhotoVideoId.hashCode ^
|
||||||
|
isFavorite.hashCode ^
|
||||||
|
isLocal.hashCode;
|
||||||
|
|
||||||
bool updateFromAssetEntity(AssetEntity ae) {
|
bool updateFromAssetEntity(AssetEntity ae) {
|
||||||
// TODO check more fields;
|
// TODO check more fields;
|
||||||
|
@ -192,9 +228,24 @@ class Asset {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static int compareByDeviceIdLocalId(Asset a, Asset b) {
|
/// compares assets by [ownerId], [deviceId], [localId]
|
||||||
final int order = a.deviceId.compareTo(b.deviceId);
|
static int compareByOwnerDeviceLocalId(Asset a, Asset b) {
|
||||||
return order == 0 ? a.localId.compareTo(b.localId) : order;
|
final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
|
||||||
|
if (ownerIdOrder != 0) {
|
||||||
|
return ownerIdOrder;
|
||||||
|
}
|
||||||
|
final int deviceIdOrder = a.deviceId.compareTo(b.deviceId);
|
||||||
|
if (deviceIdOrder != 0) {
|
||||||
|
return deviceIdOrder;
|
||||||
|
}
|
||||||
|
final int localIdOrder = a.localId.compareTo(b.localId);
|
||||||
|
return localIdOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt]
|
||||||
|
static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) {
|
||||||
|
final int order = compareByOwnerDeviceLocalId(a, b);
|
||||||
|
return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
||||||
|
@ -203,6 +254,30 @@ class Asset {
|
||||||
a.localId.compareTo(b.localId);
|
a.localId.compareTo(b.localId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AssetType {
|
||||||
|
// do not change this order!
|
||||||
|
other,
|
||||||
|
image,
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AssetTypeEnumHelper on AssetTypeEnum {
|
||||||
|
AssetType toAssetType() {
|
||||||
|
switch (this) {
|
||||||
|
case AssetTypeEnum.IMAGE:
|
||||||
|
return AssetType.image;
|
||||||
|
case AssetTypeEnum.VIDEO:
|
||||||
|
return AssetType.video;
|
||||||
|
case AssetTypeEnum.AUDIO:
|
||||||
|
return AssetType.audio;
|
||||||
|
case AssetTypeEnum.OTHER:
|
||||||
|
return AssetType.other;
|
||||||
|
}
|
||||||
|
throw Exception();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension AssetsHelper on IsarCollection<Asset> {
|
extension AssetsHelper on IsarCollection<Asset> {
|
||||||
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
|
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
|
||||||
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
|
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
|
||||||
|
|
Binary file not shown.
|
@ -138,7 +138,7 @@ class StoreKeyNotFoundException implements Exception {
|
||||||
/// Key for each possible value in the `Store`.
|
/// Key for each possible value in the `Store`.
|
||||||
/// Defines the data type for each value
|
/// Defines the data type for each value
|
||||||
enum StoreKey<T> {
|
enum StoreKey<T> {
|
||||||
userRemoteId<String>(0, type: String),
|
version<int>(0, type: int),
|
||||||
assetETag<String>(1, type: String),
|
assetETag<String>(1, type: String),
|
||||||
currentUser<User>(2, type: User, fromDb: _getUser, toDb: _toUser),
|
currentUser<User>(2, type: User, fromDb: _getUser, toDb: _toUser),
|
||||||
deviceIdHash<int>(3, type: int),
|
deviceIdHash<int>(3, type: int),
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/models/user.dart';
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
@ -12,6 +10,9 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
import 'package:immich_mobile/utils/db.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
@ -53,15 +54,18 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||||
final AssetService _assetService;
|
final AssetService _assetService;
|
||||||
final AppSettingsService _settingsService;
|
final AppSettingsService _settingsService;
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
|
final SyncService _syncService;
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
final log = Logger('AssetNotifier');
|
final log = Logger('AssetNotifier');
|
||||||
bool _getAllAssetInProgress = false;
|
bool _getAllAssetInProgress = false;
|
||||||
bool _deleteInProgress = false;
|
bool _deleteInProgress = false;
|
||||||
|
final AsyncMutex _stateUpdateLock = AsyncMutex();
|
||||||
|
|
||||||
AssetNotifier(
|
AssetNotifier(
|
||||||
this._assetService,
|
this._assetService,
|
||||||
this._settingsService,
|
this._settingsService,
|
||||||
this._albumService,
|
this._albumService,
|
||||||
|
this._syncService,
|
||||||
this._db,
|
this._db,
|
||||||
) : super(AssetsState.fromAssetList([]));
|
) : super(AssetsState.fromAssetList([]));
|
||||||
|
|
||||||
|
@ -81,24 +85,30 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||||
await _updateAssetsState(state.allAssets);
|
await _updateAssetsState(state.allAssets);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllAsset() async {
|
Future<void> getAllAsset({bool clear = false}) async {
|
||||||
if (_getAllAssetInProgress || _deleteInProgress) {
|
if (_getAllAssetInProgress || _deleteInProgress) {
|
||||||
// guard against multiple calls to this method while it's still working
|
// guard against multiple calls to this method while it's still working
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final stopwatch = Stopwatch();
|
final stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
_getAllAssetInProgress = true;
|
_getAllAssetInProgress = true;
|
||||||
final User me = Store.get(StoreKey.currentUser);
|
final User me = Store.get(StoreKey.currentUser);
|
||||||
final int cachedCount =
|
if (clear) {
|
||||||
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
|
await clearAssetsAndAlbums(_db);
|
||||||
stopwatch.start();
|
log.info("Manual refresh requested, cleared assets and albums from db");
|
||||||
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
|
} else if (_stateUpdateLock.enqueued <= 1) {
|
||||||
await _updateAssetsState(await _getUserAssets(me.isarId));
|
final int cachedCount =
|
||||||
log.info(
|
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
|
||||||
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
|
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
|
||||||
);
|
await _stateUpdateLock.run(
|
||||||
stopwatch.reset();
|
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
|
||||||
|
);
|
||||||
|
stopwatch.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final bool newRemote = await _assetService.refreshRemoteAssets();
|
final bool newRemote = await _assetService.refreshRemoteAssets();
|
||||||
final bool newLocal = await _albumService.refreshDeviceAlbums();
|
final bool newLocal = await _albumService.refreshDeviceAlbums();
|
||||||
|
@ -112,10 +122,14 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopwatch.reset();
|
stopwatch.reset();
|
||||||
final assets = await _getUserAssets(me.isarId);
|
if (_stateUpdateLock.enqueued <= 1) {
|
||||||
if (!const ListEquality().equals(assets, state.allAssets)) {
|
_stateUpdateLock.run(() async {
|
||||||
log.info("setting new asset state");
|
final assets = await _getUserAssets(me.isarId);
|
||||||
await _updateAssetsState(assets);
|
if (!const ListEquality().equals(assets, state.allAssets)) {
|
||||||
|
log.info("setting new asset state");
|
||||||
|
await _updateAssetsState(assets);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_getAllAssetInProgress = false;
|
_getAllAssetInProgress = false;
|
||||||
|
@ -130,47 +144,18 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||||
|
|
||||||
Future<void> clearAllAsset() {
|
Future<void> clearAllAsset() {
|
||||||
state = AssetsState.empty();
|
state = AssetsState.empty();
|
||||||
return _db.writeTxn(() async {
|
return clearAssetsAndAlbums(_db);
|
||||||
await _db.assets.clear();
|
|
||||||
await _db.exifInfos.clear();
|
|
||||||
await _db.albums.clear();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onNewAssetUploaded(Asset newAsset) async {
|
Future<void> onNewAssetUploaded(Asset newAsset) async {
|
||||||
final int i = state.allAssets.indexWhere(
|
final bool ok = await _syncService.syncNewAssetToDb(newAsset);
|
||||||
(a) =>
|
if (ok && _stateUpdateLock.enqueued <= 1) {
|
||||||
a.isRemote ||
|
// run this sequentially if there is at most 1 other task waiting
|
||||||
(a.localId == newAsset.localId && a.deviceId == newAsset.deviceId),
|
await _stateUpdateLock.run(() async {
|
||||||
);
|
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||||
|
final assets = await _getUserAssets(userId);
|
||||||
if (i == -1 ||
|
await _updateAssetsState(assets);
|
||||||
state.allAssets[i].localId != newAsset.localId ||
|
});
|
||||||
state.allAssets[i].deviceId != newAsset.deviceId) {
|
|
||||||
await _updateAssetsState([...state.allAssets, newAsset]);
|
|
||||||
} else {
|
|
||||||
// unify local/remote assets by replacing the
|
|
||||||
// local-only asset in the DB with a local&remote asset
|
|
||||||
final Asset? inDb = await _db.assets
|
|
||||||
.where()
|
|
||||||
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
|
|
||||||
.findFirst();
|
|
||||||
if (inDb != null) {
|
|
||||||
newAsset.id = inDb.id;
|
|
||||||
newAsset.isLocal = inDb.isLocal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// order is important to keep all local-only assets at the beginning!
|
|
||||||
await _updateAssetsState([
|
|
||||||
...state.allAssets.slice(0, i),
|
|
||||||
...state.allAssets.slice(i + 1),
|
|
||||||
newAsset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await _db.writeTxn(() => newAsset.put(_db));
|
|
||||||
} on IsarError catch (e) {
|
|
||||||
debugPrint(e.toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -253,6 +238,7 @@ final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
|
||||||
ref.watch(assetServiceProvider),
|
ref.watch(assetServiceProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
ref.watch(albumServiceProvider),
|
ref.watch(albumServiceProvider),
|
||||||
|
ref.watch(syncServiceProvider),
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -45,14 +45,11 @@ class AssetService {
|
||||||
.filter()
|
.filter()
|
||||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||||
.count();
|
.count();
|
||||||
final List<AssetResponseDto>? dtos =
|
final bool changes = await _syncService.syncRemoteAssetsToDb(
|
||||||
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0);
|
() async => (await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0))
|
||||||
if (dtos == null) {
|
?.map(Asset.remote)
|
||||||
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms");
|
.toList(),
|
||||||
return false;
|
);
|
||||||
}
|
|
||||||
final bool changes = await _syncService
|
|
||||||
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
|
|
||||||
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
|
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ class ImmichLogger {
|
||||||
static final ImmichLogger _instance = ImmichLogger._internal();
|
static final ImmichLogger _instance = ImmichLogger._internal();
|
||||||
final maxLogEntries = 200;
|
final maxLogEntries = 200;
|
||||||
final Isar _db = Isar.getInstance()!;
|
final Isar _db = Isar.getInstance()!;
|
||||||
final List<LoggerMessage> _msgBuffer = [];
|
List<LoggerMessage> _msgBuffer = [];
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
factory ImmichLogger() => _instance;
|
factory ImmichLogger() => _instance;
|
||||||
|
@ -41,7 +41,12 @@ class ImmichLogger {
|
||||||
final msgCount = _db.loggerMessages.countSync();
|
final msgCount = _db.loggerMessages.countSync();
|
||||||
if (msgCount > maxLogEntries) {
|
if (msgCount > maxLogEntries) {
|
||||||
final numberOfEntryToBeDeleted = msgCount - maxLogEntries;
|
final numberOfEntryToBeDeleted = msgCount - maxLogEntries;
|
||||||
_db.loggerMessages.where().limit(numberOfEntryToBeDeleted).deleteAll();
|
_db.writeTxn(
|
||||||
|
() => _db.loggerMessages
|
||||||
|
.where()
|
||||||
|
.limit(numberOfEntryToBeDeleted)
|
||||||
|
.deleteAll(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,8 +68,9 @@ class ImmichLogger {
|
||||||
|
|
||||||
void _flushBufferToDatabase() {
|
void _flushBufferToDatabase() {
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
|
final buffer = _msgBuffer;
|
||||||
_msgBuffer.clear();
|
_msgBuffer = [];
|
||||||
|
_db.writeTxn(() => _db.loggerMessages.putAll(buffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearLogs() {
|
void clearLogs() {
|
||||||
|
@ -111,7 +117,7 @@ class ImmichLogger {
|
||||||
void flush() {
|
void flush() {
|
||||||
if (_timer != null) {
|
if (_timer != null) {
|
||||||
_timer!.cancel();
|
_timer!.cancel();
|
||||||
_flushBufferToDatabase();
|
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
@ -61,8 +60,10 @@ class SyncService {
|
||||||
|
|
||||||
/// Syncs remote assets owned by the logged-in user to the DB
|
/// Syncs remote assets owned by the logged-in user to the DB
|
||||||
/// Returns `true` if there were any changes
|
/// Returns `true` if there were any changes
|
||||||
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) =>
|
Future<bool> syncRemoteAssetsToDb(
|
||||||
_lock.run(() => _syncRemoteAssetsToDb(remote));
|
FutureOr<List<Asset>?> Function() loadAssets,
|
||||||
|
) =>
|
||||||
|
_lock.run(() => _syncRemoteAssetsToDb(loadAssets));
|
||||||
|
|
||||||
/// Syncs remote albums to the database
|
/// Syncs remote albums to the database
|
||||||
/// returns `true` if there were any changes
|
/// returns `true` if there were any changes
|
||||||
|
@ -97,19 +98,72 @@ class SyncService {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Syncs a new asset to the db. Returns `true` if successful
|
||||||
|
Future<bool> syncNewAssetToDb(Asset newAsset) =>
|
||||||
|
_lock.run(() => _syncNewAssetToDb(newAsset));
|
||||||
|
|
||||||
// private methods:
|
// private methods:
|
||||||
|
|
||||||
|
/// Syncs a new asset to the db. Returns `true` if successful
|
||||||
|
Future<bool> _syncNewAssetToDb(Asset newAsset) async {
|
||||||
|
final List<Asset> inDb = await _db.assets
|
||||||
|
.where()
|
||||||
|
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
|
||||||
|
.findAll();
|
||||||
|
Asset? match;
|
||||||
|
if (inDb.length == 1) {
|
||||||
|
// exactly one match: trivial case
|
||||||
|
match = inDb.first;
|
||||||
|
} else if (inDb.length > 1) {
|
||||||
|
// TODO instead of this heuristics: match by checksum once available
|
||||||
|
for (Asset a in inDb) {
|
||||||
|
if (a.ownerId == newAsset.ownerId &&
|
||||||
|
a.fileModifiedAt == newAsset.fileModifiedAt) {
|
||||||
|
assert(match == null);
|
||||||
|
match = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match == null) {
|
||||||
|
for (Asset a in inDb) {
|
||||||
|
if (a.ownerId == newAsset.ownerId) {
|
||||||
|
assert(match == null);
|
||||||
|
match = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match != null) {
|
||||||
|
// unify local/remote assets by replacing the
|
||||||
|
// local-only asset in the DB with a local&remote asset
|
||||||
|
newAsset.updateFromDb(match);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() => newAsset.put(_db));
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
_log.severe("Failed to put new asset into db: $e");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Syncs remote assets to the databas
|
/// Syncs remote assets to the databas
|
||||||
/// returns `true` if there were any changes
|
/// returns `true` if there were any changes
|
||||||
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async {
|
Future<bool> _syncRemoteAssetsToDb(
|
||||||
|
FutureOr<List<Asset>?> Function() loadAssets,
|
||||||
|
) async {
|
||||||
|
final List<Asset>? remote = await loadAssets();
|
||||||
|
if (remote == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final User user = Store.get(StoreKey.currentUser);
|
final User user = Store.get(StoreKey.currentUser);
|
||||||
final List<Asset> inDb = await _db.assets
|
final List<Asset> inDb = await _db.assets
|
||||||
.filter()
|
.filter()
|
||||||
.ownerIdEqualTo(user.isarId)
|
.ownerIdEqualTo(user.isarId)
|
||||||
.sortByDeviceId()
|
.sortByDeviceId()
|
||||||
.thenByLocalId()
|
.thenByLocalId()
|
||||||
|
.thenByFileModifiedAt()
|
||||||
.findAll();
|
.findAll();
|
||||||
remote.sort(Asset.compareByDeviceIdLocalId);
|
remote.sort(Asset.compareByOwnerDeviceLocalIdModified);
|
||||||
final diff = _diffAssets(remote, inDb, remote: true);
|
final diff = _diffAssets(remote, inDb, remote: true);
|
||||||
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
|
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -119,7 +173,7 @@ class SyncService {
|
||||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
||||||
await _upsertAssetsWithExif(diff.first + diff.second);
|
await _upsertAssetsWithExif(diff.first + diff.second);
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
debugPrint(e.toString());
|
_log.severe("Failed to sync remote assets to db: $e");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -188,10 +242,15 @@ class SyncService {
|
||||||
if (dto.assetCount != dto.assets.length) {
|
if (dto.assetCount != dto.assets.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final assetsInDb =
|
final assetsInDb = await album.assets
|
||||||
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll();
|
.filter()
|
||||||
|
.sortByOwnerId()
|
||||||
|
.thenByDeviceId()
|
||||||
|
.thenByLocalId()
|
||||||
|
.thenByFileModifiedAt()
|
||||||
|
.findAll();
|
||||||
final List<Asset> assetsOnRemote = dto.getAssets();
|
final List<Asset> assetsOnRemote = dto.getAssets();
|
||||||
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId);
|
assetsOnRemote.sort(Asset.compareByOwnerDeviceLocalIdModified);
|
||||||
final d = _diffAssets(assetsOnRemote, assetsInDb);
|
final d = _diffAssets(assetsOnRemote, assetsInDb);
|
||||||
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
|
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
|
||||||
|
|
||||||
|
@ -237,7 +296,7 @@ class SyncService {
|
||||||
await _db.albums.put(album);
|
await _db.albums.put(album);
|
||||||
});
|
});
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
debugPrint(e.toString());
|
_log.severe("Failed to sync remote album to database $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (album.shared || dto.shared) {
|
if (album.shared || dto.shared) {
|
||||||
|
@ -300,7 +359,7 @@ class SyncService {
|
||||||
assert(ok);
|
assert(ok);
|
||||||
_log.info("Removed local album $album from DB");
|
_log.info("Removed local album $album from DB");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warning("Failed to remove local album $album from DB");
|
_log.severe("Failed to remove local album $album from DB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,7 +390,7 @@ class SyncService {
|
||||||
_addAlbumFromDevice(ape, existing, excludedAssets),
|
_addAlbumFromDevice(ape, existing, excludedAssets),
|
||||||
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||||
);
|
);
|
||||||
final pair = _handleAssetRemoval(deleteCandidates, existing);
|
final pair = _handleAssetRemoval(deleteCandidates, existing, remote: false);
|
||||||
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
|
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
|
||||||
await _db.writeTxn(() async {
|
await _db.writeTxn(() async {
|
||||||
await _db.assets.deleteAll(pair.first);
|
await _db.assets.deleteAll(pair.first);
|
||||||
|
@ -366,7 +425,12 @@ class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||||
final inDb = await album.assets.filter().sortByLocalId().findAll();
|
final inDb = await album.assets
|
||||||
|
.filter()
|
||||||
|
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||||
|
.deviceIdEqualTo(Store.get(StoreKey.deviceIdHash))
|
||||||
|
.sortByLocalId()
|
||||||
|
.findAll();
|
||||||
final List<Asset> onDevice =
|
final List<Asset> onDevice =
|
||||||
await ape.getAssets(excludedAssets: excludedAssets);
|
await ape.getAssets(excludedAssets: excludedAssets);
|
||||||
onDevice.sort(Asset.compareByLocalId);
|
onDevice.sort(Asset.compareByLocalId);
|
||||||
|
@ -401,7 +465,7 @@ class SyncService {
|
||||||
});
|
});
|
||||||
_log.info("Synced changes of local album $ape to DB");
|
_log.info("Synced changes of local album $ape to DB");
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
_log.warning("Failed to update synced album $ape in DB: $e");
|
_log.severe("Failed to update synced album $ape in DB: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -438,7 +502,7 @@ class SyncService {
|
||||||
});
|
});
|
||||||
_log.info("Fast synced local album $ape to DB");
|
_log.info("Fast synced local album $ape to DB");
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
_log.warning("Failed to fast sync local album $ape to DB: $e");
|
_log.severe("Failed to fast sync local album $ape to DB: $e");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -470,7 +534,7 @@ class SyncService {
|
||||||
await _db.writeTxn(() => _db.albums.store(a));
|
await _db.writeTxn(() => _db.albums.store(a));
|
||||||
_log.info("Added a new local album to DB: $ape");
|
_log.info("Added a new local album to DB: $ape");
|
||||||
} on IsarError catch (e) {
|
} on IsarError catch (e) {
|
||||||
_log.warning("Failed to add new local album $ape to DB: $e");
|
_log.severe("Failed to add new local album $ape to DB: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -487,15 +551,19 @@ class SyncService {
|
||||||
assets,
|
assets,
|
||||||
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
|
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
|
||||||
)
|
)
|
||||||
.sortByDeviceId()
|
.sortByOwnerId()
|
||||||
|
.thenByDeviceId()
|
||||||
.thenByLocalId()
|
.thenByLocalId()
|
||||||
|
.thenByFileModifiedAt()
|
||||||
.findAll();
|
.findAll();
|
||||||
assets.sort(Asset.compareByDeviceIdLocalId);
|
assets.sort(Asset.compareByOwnerDeviceLocalIdModified);
|
||||||
final List<Asset> existing = [], toUpsert = [];
|
final List<Asset> existing = [], toUpsert = [];
|
||||||
diffSortedListsSync(
|
diffSortedListsSync(
|
||||||
inDb,
|
inDb,
|
||||||
assets,
|
assets,
|
||||||
compare: Asset.compareByDeviceIdLocalId,
|
// do not compare by modified date because for some assets dates differ on
|
||||||
|
// client and server, thus never reaching "both" case below
|
||||||
|
compare: Asset.compareByOwnerDeviceLocalId,
|
||||||
both: (Asset a, Asset b) {
|
both: (Asset a, Asset b) {
|
||||||
if ((a.isLocal || !b.isLocal) &&
|
if ((a.isLocal || !b.isLocal) &&
|
||||||
(a.isRemote || !b.isRemote) &&
|
(a.isRemote || !b.isRemote) &&
|
||||||
|
@ -541,7 +609,7 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
|
||||||
List<Asset> assets,
|
List<Asset> assets,
|
||||||
List<Asset> inDb, {
|
List<Asset> inDb, {
|
||||||
bool? remote,
|
bool? remote,
|
||||||
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId,
|
int Function(Asset, Asset) compare = Asset.compareByOwnerDeviceLocalId,
|
||||||
}) {
|
}) {
|
||||||
final List<Asset> toAdd = [];
|
final List<Asset> toAdd = [];
|
||||||
final List<Asset> toUpdate = [];
|
final List<Asset> toUpdate = [];
|
||||||
|
@ -582,15 +650,20 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
|
||||||
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
||||||
Pair<List<int>, List<Asset>> _handleAssetRemoval(
|
Pair<List<int>, List<Asset>> _handleAssetRemoval(
|
||||||
List<Asset> deleteCandidates,
|
List<Asset> deleteCandidates,
|
||||||
List<Asset> existing,
|
List<Asset> existing, {
|
||||||
) {
|
bool? remote,
|
||||||
|
}) {
|
||||||
if (deleteCandidates.isEmpty) {
|
if (deleteCandidates.isEmpty) {
|
||||||
return const Pair([], []);
|
return const Pair([], []);
|
||||||
}
|
}
|
||||||
deleteCandidates.sort(Asset.compareById);
|
deleteCandidates.sort(Asset.compareById);
|
||||||
existing.sort(Asset.compareById);
|
existing.sort(Asset.compareById);
|
||||||
final triple =
|
final triple = _diffAssets(
|
||||||
_diffAssets(existing, deleteCandidates, compare: Asset.compareById);
|
existing,
|
||||||
|
deleteCandidates,
|
||||||
|
compare: Asset.compareById,
|
||||||
|
remote: remote,
|
||||||
|
);
|
||||||
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
|
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,17 @@ import 'dart:async';
|
||||||
/// Async mutex to guarantee actions are performed sequentially and do not interleave
|
/// Async mutex to guarantee actions are performed sequentially and do not interleave
|
||||||
class AsyncMutex {
|
class AsyncMutex {
|
||||||
Future _running = Future.value(null);
|
Future _running = Future.value(null);
|
||||||
|
int _enqueued = 0;
|
||||||
|
|
||||||
|
get enqueued => _enqueued;
|
||||||
|
|
||||||
/// Execute [operation] exclusively, after any currently running operations.
|
/// Execute [operation] exclusively, after any currently running operations.
|
||||||
/// Returns a [Future] with the result of the [operation].
|
/// Returns a [Future] with the result of the [operation].
|
||||||
Future<T> run<T>(Future<T> Function() operation) {
|
Future<T> run<T>(Future<T> Function() operation) {
|
||||||
final completer = Completer<T>();
|
final completer = Completer<T>();
|
||||||
|
_enqueued++;
|
||||||
_running.whenComplete(() {
|
_running.whenComplete(() {
|
||||||
|
_enqueued--;
|
||||||
completer.complete(Future<T>.sync(operation));
|
completer.complete(Future<T>.sync(operation));
|
||||||
});
|
});
|
||||||
return _running = completer.future;
|
return _running = completer.future;
|
||||||
|
|
14
mobile/lib/utils/db.dart
Normal file
14
mobile/lib/utils/db.dart
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
Future<void> clearAssetsAndAlbums(Isar db) async {
|
||||||
|
await Store.delete(StoreKey.assetETag);
|
||||||
|
await db.writeTxn(() async {
|
||||||
|
await db.assets.clear();
|
||||||
|
await db.exifInfos.clear();
|
||||||
|
await db.albums.clear();
|
||||||
|
});
|
||||||
|
}
|
|
@ -44,6 +44,9 @@ class FileHelper {
|
||||||
case '3gp':
|
case '3gp':
|
||||||
return {"type": "video", "subType": "3gpp"};
|
return {"type": "video", "subType": "3gpp"};
|
||||||
|
|
||||||
|
case 'webm':
|
||||||
|
return {"type": "video", "subType": "webm"};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {"type": "unsupport", "subType": "unsupport"};
|
return {"type": "unsupport", "subType": "unsupport"};
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
|
||||||
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/db.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
Future<void> migrateHiveToStoreIfNecessary() async {
|
Future<void> migrateHiveToStoreIfNecessary() async {
|
||||||
|
@ -53,7 +54,6 @@ Future<void> _migrateLoginInfoBox(Box<HiveSavedLoginInfo> box) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateHiveUserInfoBox(Box box) async {
|
Future<void> _migrateHiveUserInfoBox(Box box) async {
|
||||||
await _migrateKey(box, userIdKey, StoreKey.userRemoteId);
|
|
||||||
await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
|
await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
|
||||||
if (Store.tryGet(StoreKey.deviceId) == null) {
|
if (Store.tryGet(StoreKey.deviceId) == null) {
|
||||||
await _migrateKey(box, deviceIdKey, StoreKey.deviceId);
|
await _migrateKey(box, deviceIdKey, StoreKey.deviceId);
|
||||||
|
@ -143,3 +143,16 @@ Future<void> migrateJsonCacheIfNecessary() async {
|
||||||
await SharedAlbumCacheService().invalidate();
|
await SharedAlbumCacheService().invalidate();
|
||||||
await AssetCacheService().invalidate();
|
await AssetCacheService().invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
|
final int version = Store.get(StoreKey.version, 1);
|
||||||
|
switch (version) {
|
||||||
|
case 1:
|
||||||
|
await _migrateV1ToV2(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateV1ToV2(Isar db) async {
|
||||||
|
await clearAssetsAndAlbums(db);
|
||||||
|
await Store.put(StoreKey.version, 2);
|
||||||
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ void main() {
|
||||||
fileModifiedAt: date,
|
fileModifiedAt: date,
|
||||||
updatedAt: date,
|
updatedAt: date,
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
|
type: AssetType.image,
|
||||||
fileName: '',
|
fileName: '',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
|
|
41
mobile/test/async_mutex_test.dart
Normal file
41
mobile/test/async_mutex_test.dart
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Test AsyncMutex grouped', () {
|
||||||
|
test('test ordered execution', () async {
|
||||||
|
AsyncMutex lock = AsyncMutex();
|
||||||
|
List<int> events = [];
|
||||||
|
expect(0, lock.enqueued);
|
||||||
|
lock.run(
|
||||||
|
() => Future.delayed(
|
||||||
|
const Duration(milliseconds: 10),
|
||||||
|
() => events.add(1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(1, lock.enqueued);
|
||||||
|
lock.run(
|
||||||
|
() => Future.delayed(
|
||||||
|
const Duration(milliseconds: 3),
|
||||||
|
() => events.add(2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(2, lock.enqueued);
|
||||||
|
lock.run(
|
||||||
|
() => Future.delayed(
|
||||||
|
const Duration(milliseconds: 1),
|
||||||
|
() => events.add(3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(3, lock.enqueued);
|
||||||
|
await lock.run(
|
||||||
|
() => Future.delayed(
|
||||||
|
const Duration(milliseconds: 10),
|
||||||
|
() => events.add(4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(0, lock.enqueued);
|
||||||
|
expect(events, [1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ Asset _getTestAsset(int id, bool favorite) {
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
|
type: AssetType.image,
|
||||||
fileName: '',
|
fileName: '',
|
||||||
isFavorite: favorite,
|
isFavorite: favorite,
|
||||||
);
|
);
|
||||||
|
|
143
mobile/test/sync_service_test.dart
Normal file
143
mobile/test/sync_service_test.dart
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/logger_message.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Asset makeAsset({
|
||||||
|
required String localId,
|
||||||
|
String? remoteId,
|
||||||
|
int deviceId = 1,
|
||||||
|
int ownerId = 590700560494856554, // hash of "1"
|
||||||
|
bool isLocal = false,
|
||||||
|
}) {
|
||||||
|
final DateTime date = DateTime(2000);
|
||||||
|
return Asset(
|
||||||
|
localId: localId,
|
||||||
|
remoteId: remoteId,
|
||||||
|
deviceId: deviceId,
|
||||||
|
ownerId: ownerId,
|
||||||
|
fileCreatedAt: date,
|
||||||
|
fileModifiedAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
durationInSeconds: 0,
|
||||||
|
type: AssetType.image,
|
||||||
|
fileName: localId,
|
||||||
|
isFavorite: false,
|
||||||
|
isLocal: isLocal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Isar loadDb() {
|
||||||
|
return Isar.openSync(
|
||||||
|
[
|
||||||
|
ExifInfoSchema,
|
||||||
|
AssetSchema,
|
||||||
|
AlbumSchema,
|
||||||
|
UserSchema,
|
||||||
|
StoreValueSchema,
|
||||||
|
LoggerMessageSchema
|
||||||
|
],
|
||||||
|
maxSizeMiB: 256,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('Test SyncService grouped', () {
|
||||||
|
late final Isar db;
|
||||||
|
setUpAll(() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await Isar.initializeIsarCore(download: true);
|
||||||
|
db = loadDb();
|
||||||
|
ImmichLogger();
|
||||||
|
db.writeTxnSync(() => db.clearSync());
|
||||||
|
Store.init(db);
|
||||||
|
await Store.put(
|
||||||
|
StoreKey.currentUser,
|
||||||
|
User(
|
||||||
|
id: "1",
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
email: "a@b.c",
|
||||||
|
firstName: "first",
|
||||||
|
lastName: "last",
|
||||||
|
isAdmin: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final List<Asset> initialAssets = [
|
||||||
|
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
|
||||||
|
makeAsset(localId: "1", remoteId: "1-1", isLocal: true),
|
||||||
|
makeAsset(localId: "2", isLocal: true),
|
||||||
|
makeAsset(localId: "3", isLocal: true),
|
||||||
|
];
|
||||||
|
setUp(() {
|
||||||
|
db.writeTxnSync(() {
|
||||||
|
db.assets.clearSync();
|
||||||
|
db.assets.putAllSync(initialAssets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('test inserting existing assets', () async {
|
||||||
|
SyncService s = SyncService(db);
|
||||||
|
final List<Asset> remoteAssets = [
|
||||||
|
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
|
||||||
|
makeAsset(localId: "1", remoteId: "1-1"),
|
||||||
|
];
|
||||||
|
expect(db.assets.countSync(), 5);
|
||||||
|
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
|
||||||
|
expect(c1, false);
|
||||||
|
expect(db.assets.countSync(), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test inserting new assets', () async {
|
||||||
|
SyncService s = SyncService(db);
|
||||||
|
final List<Asset> remoteAssets = [
|
||||||
|
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
|
||||||
|
makeAsset(localId: "1", remoteId: "1-1"),
|
||||||
|
makeAsset(localId: "2", remoteId: "1-2"),
|
||||||
|
makeAsset(localId: "4", remoteId: "1-4"),
|
||||||
|
makeAsset(localId: "1", remoteId: "3-1", deviceId: 3),
|
||||||
|
];
|
||||||
|
expect(db.assets.countSync(), 5);
|
||||||
|
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
|
||||||
|
expect(c1, true);
|
||||||
|
expect(db.assets.countSync(), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test syncing duplicate assets', () async {
|
||||||
|
SyncService s = SyncService(db);
|
||||||
|
final List<Asset> remoteAssets = [
|
||||||
|
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
|
||||||
|
makeAsset(localId: "1", remoteId: "1-1"),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1b", deviceId: 2),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1c", deviceId: 2),
|
||||||
|
makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2),
|
||||||
|
];
|
||||||
|
expect(db.assets.countSync(), 5);
|
||||||
|
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
|
||||||
|
expect(c1, true);
|
||||||
|
expect(db.assets.countSync(), 8);
|
||||||
|
final bool c2 = await s.syncRemoteAssetsToDb(() => remoteAssets);
|
||||||
|
expect(c2, false);
|
||||||
|
expect(db.assets.countSync(), 8);
|
||||||
|
remoteAssets.removeAt(4);
|
||||||
|
final bool c3 = await s.syncRemoteAssetsToDb(() => remoteAssets);
|
||||||
|
expect(c3, true);
|
||||||
|
expect(db.assets.countSync(), 7);
|
||||||
|
remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2));
|
||||||
|
remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2));
|
||||||
|
final bool c4 = await s.syncRemoteAssetsToDb(() => remoteAssets);
|
||||||
|
expect(c4, true);
|
||||||
|
expect(db.assets.countSync(), 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -12,8 +12,11 @@ import {
|
||||||
ServerInfoApi,
|
ServerInfoApi,
|
||||||
ShareApi,
|
ShareApi,
|
||||||
SystemConfigApi,
|
SystemConfigApi,
|
||||||
|
ThumbnailFormat,
|
||||||
UserApi
|
UserApi
|
||||||
} from './open-api';
|
} from './open-api';
|
||||||
|
import { BASE_PATH } from './open-api/base';
|
||||||
|
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
|
||||||
|
|
||||||
export class ImmichApi {
|
export class ImmichApi {
|
||||||
public userApi: UserApi;
|
public userApi: UserApi;
|
||||||
|
@ -48,6 +51,21 @@ export class ImmichApi {
|
||||||
this.shareApi = new ShareApi(this.config);
|
this.shareApi = new ShareApi(this.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createUrl(path: string, params?: Record<string, unknown>) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
for (const key in params) {
|
||||||
|
const value = params[key];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
searchParams.set(key, value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(path, DUMMY_BASE_URL);
|
||||||
|
url.search = searchParams.toString();
|
||||||
|
|
||||||
|
return (this.config.basePath || BASE_PATH) + toPathString(url);
|
||||||
|
}
|
||||||
|
|
||||||
public setAccessToken(accessToken: string) {
|
public setAccessToken(accessToken: string) {
|
||||||
this.config.accessToken = accessToken;
|
this.config.accessToken = accessToken;
|
||||||
}
|
}
|
||||||
|
@ -59,6 +77,16 @@ export class ImmichApi {
|
||||||
public setBaseUrl(baseUrl: string) {
|
public setBaseUrl(baseUrl: string) {
|
||||||
this.config.basePath = baseUrl;
|
this.config.basePath = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getAssetFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean, key?: string) {
|
||||||
|
const path = `/asset/file/${assetId}`;
|
||||||
|
return this.createUrl(path, { isThumb, isWeb, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAssetThumbnailUrl(assetId: string, format?: ThumbnailFormat, key?: string) {
|
||||||
|
const path = `/asset/thumbnail/${assetId}`;
|
||||||
|
return this.createUrl(path, { format, key });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ImmichApi({ basePath: '/api' });
|
export const api = new ImmichApi({ basePath: '/api' });
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { quintOut } from 'svelte/easing';
|
import { quintOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div class="flex flex-wrap gap-[2px]">
|
<div class="flex flex-wrap gap-[2px]">
|
||||||
{#each album.assets as asset}
|
{#each album.assets as asset}
|
||||||
<ImmichThumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
on:click={() => (selectedThumbnail = asset)}
|
on:click={() => (selectedThumbnail = asset)}
|
||||||
selected={isSelected(asset.id)}
|
selected={isSelected(asset.id)}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let url: string;
|
||||||
|
export let altText: string;
|
||||||
|
export let heightStyle: string;
|
||||||
|
export let widthStyle: string;
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img
|
||||||
|
style:width={widthStyle}
|
||||||
|
style:height={heightStyle}
|
||||||
|
src={url}
|
||||||
|
alt={altText}
|
||||||
|
class="object-cover transition-opacity duration-300"
|
||||||
|
class:opacity-0={loading}
|
||||||
|
draggable="false"
|
||||||
|
on:load|once={() => (loading = false)}
|
||||||
|
/>
|
140
web/src/lib/components/assets/thumbnail/thumbnail.svelte
Normal file
140
web/src/lib/components/assets/thumbnail/thumbnail.svelte
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||||
|
import { timeToSeconds } from '$lib/utils/time-to-seconds';
|
||||||
|
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||||
|
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||||
|
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||||
|
import Star from 'svelte-material-icons/Star.svelte';
|
||||||
|
import ImageThumbnail from './image-thumbnail.svelte';
|
||||||
|
import VideoThumbnail from './video-thumbnail.svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let asset: AssetResponseDto;
|
||||||
|
export let groupIndex = 0;
|
||||||
|
export let thumbnailSize: number | undefined = undefined;
|
||||||
|
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||||
|
export let selected = false;
|
||||||
|
export let disabled = false;
|
||||||
|
export let readonly = false;
|
||||||
|
export let publicSharedKey: string | undefined = undefined;
|
||||||
|
|
||||||
|
let mouseOver = false;
|
||||||
|
|
||||||
|
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||||
|
|
||||||
|
$: [width, height] = (() => {
|
||||||
|
if (thumbnailSize) {
|
||||||
|
return [thumbnailSize, thumbnailSize];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
|
||||||
|
return [176, 235];
|
||||||
|
} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
|
||||||
|
return [313, 235];
|
||||||
|
} else {
|
||||||
|
return [235, 235];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const thumbnailClickedHandler = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
dispatch('click', { asset });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onIconClickedHandler = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) {
|
||||||
|
dispatch('select', { asset });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<IntersectionObserver once={false} let:intersecting>
|
||||||
|
<div
|
||||||
|
style:width="{width}px"
|
||||||
|
style:height="{height}px"
|
||||||
|
class="relative group {disabled ? 'bg-gray-300' : 'bg-immich-primary/20'}"
|
||||||
|
class:cursor-not-allowed={disabled}
|
||||||
|
class:hover:cursor-pointer={!disabled}
|
||||||
|
on:mouseenter={() => (mouseOver = true)}
|
||||||
|
on:mouseleave={() => (mouseOver = false)}
|
||||||
|
on:click={thumbnailClickedHandler}
|
||||||
|
on:keydown={thumbnailClickedHandler}
|
||||||
|
>
|
||||||
|
{#if intersecting}
|
||||||
|
<div class="absolute w-full h-full z-20">
|
||||||
|
<!-- Select asset button -->
|
||||||
|
{#if !readonly}
|
||||||
|
<button
|
||||||
|
on:click={onIconClickedHandler}
|
||||||
|
class="absolute p-2 group-hover:block"
|
||||||
|
class:group-hover:block={!disabled}
|
||||||
|
class:hidden={!selected}
|
||||||
|
class:cursor-not-allowed={disabled}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selected}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
{#if disabled}
|
||||||
|
<CheckCircle size="24" class="text-zinc-800" />
|
||||||
|
{:else if selected}
|
||||||
|
<CheckCircle size="24" class="text-immich-primary" />
|
||||||
|
{:else}
|
||||||
|
<CheckCircle size="24" class="text-white/80 hover:text-white" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-gray-100 dark:bg-immich-dark-gray absolute select-none transition-transform"
|
||||||
|
class:scale-[0.85]={selected}
|
||||||
|
>
|
||||||
|
<!-- Gradient overlay on hover -->
|
||||||
|
<div
|
||||||
|
class="absolute w-full h-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Favorite asset star -->
|
||||||
|
{#if asset.isFavorite && !publicSharedKey}
|
||||||
|
<div class="absolute bottom-2 left-2 z-10">
|
||||||
|
<Star size="24" class="text-white" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ImageThumbnail
|
||||||
|
url={api.getAssetThumbnailUrl(asset.id, format, publicSharedKey)}
|
||||||
|
altText={asset.exifInfo?.imageName ?? asset.id}
|
||||||
|
widthStyle="{width}px"
|
||||||
|
heightStyle="{height}px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if asset.type === AssetTypeEnum.Video}
|
||||||
|
<div class="absolute w-full h-full top-0">
|
||||||
|
<VideoThumbnail
|
||||||
|
url={api.getAssetFileUrl(asset.id, false, true, publicSharedKey)}
|
||||||
|
enablePlayback={mouseOver}
|
||||||
|
durationInSeconds={timeToSeconds(asset.duration)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||||
|
<div class="absolute w-full h-full top-0">
|
||||||
|
<VideoThumbnail
|
||||||
|
url={api.getAssetFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey)}
|
||||||
|
pauseIcon={MotionPauseOutline}
|
||||||
|
playIcon={MotionPlayOutline}
|
||||||
|
showTime={false}
|
||||||
|
playbackOnIconHover
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</IntersectionObserver>
|
|
@ -0,0 +1,88 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Duration } from 'luxon';
|
||||||
|
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||||
|
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||||
|
import AlertCircleOutline from 'svelte-material-icons/AlertCircleOutline.svelte';
|
||||||
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
|
|
||||||
|
export let url: string;
|
||||||
|
export let durationInSeconds = 0;
|
||||||
|
export let enablePlayback = false;
|
||||||
|
export let playbackOnIconHover = false;
|
||||||
|
export let showTime = true;
|
||||||
|
export let playIcon = PlayCircleOutline;
|
||||||
|
export let pauseIcon = PauseCircleOutline;
|
||||||
|
|
||||||
|
let remainingSeconds = durationInSeconds;
|
||||||
|
let loading = true;
|
||||||
|
let error = false;
|
||||||
|
let player: HTMLVideoElement;
|
||||||
|
|
||||||
|
$: if (!enablePlayback) {
|
||||||
|
// Reset remaining time when playback is disabled.
|
||||||
|
remainingSeconds = durationInSeconds;
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
// Cancel video buffering.
|
||||||
|
player.src = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-0 text-white text-xs font-medium flex gap-1 place-items-center z-20"
|
||||||
|
>
|
||||||
|
{#if showTime}
|
||||||
|
<span class="pt-2">
|
||||||
|
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="pt-2 pr-2"
|
||||||
|
on:mouseenter={() => {
|
||||||
|
if (playbackOnIconHover) {
|
||||||
|
enablePlayback = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
on:mouseleave={() => {
|
||||||
|
if (playbackOnIconHover) {
|
||||||
|
enablePlayback = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if enablePlayback}
|
||||||
|
{#if loading}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:else if error}
|
||||||
|
<AlertCircleOutline size="24" class="text-red-600" />
|
||||||
|
{:else}
|
||||||
|
<svelte:component this={pauseIcon} size="24" />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<svelte:component this={playIcon} size="24" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if enablePlayback}
|
||||||
|
<video
|
||||||
|
bind:this={player}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
src={url}
|
||||||
|
on:play={() => {
|
||||||
|
loading = false;
|
||||||
|
error = false;
|
||||||
|
}}
|
||||||
|
on:error={() => {
|
||||||
|
error = true;
|
||||||
|
loading = false;
|
||||||
|
}}
|
||||||
|
on:timeupdate={({ currentTarget }) => {
|
||||||
|
const remaining = currentTarget.duration - currentTarget.currentTime;
|
||||||
|
remainingSeconds = Math.min(Math.ceil(remaining), durationInSeconds);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
|
@ -5,7 +5,6 @@
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { AssetResponseDto } from '@api';
|
import { AssetResponseDto } from '@api';
|
||||||
import lodash from 'lodash-es';
|
import lodash from 'lodash-es';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
|
||||||
import {
|
import {
|
||||||
assetInteractionStore,
|
assetInteractionStore,
|
||||||
assetsInAlbumStoreState,
|
assetsInAlbumStoreState,
|
||||||
|
@ -14,6 +13,7 @@
|
||||||
selectedGroup
|
selectedGroup
|
||||||
} from '$lib/stores/asset-interaction.store';
|
} from '$lib/stores/asset-interaction.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let bucketDate: string;
|
export let bucketDate: string;
|
||||||
|
@ -156,7 +156,7 @@
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div class="flex flex-wrap gap-[2px]">
|
<div class="flex flex-wrap gap-[2px]">
|
||||||
{#each assetsInDateGroup as asset (asset.id)}
|
{#each assetsInDateGroup as asset (asset.id)}
|
||||||
<ImmichThumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
|
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
|
||||||
|
|
||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte';
|
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||||
|
@ -93,7 +93,7 @@
|
||||||
{#if assets.length > 0}
|
{#if assets.length > 0}
|
||||||
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
||||||
{#each assets as asset (asset.id)}
|
{#each assets as asset (asset.id)}
|
||||||
<ImmichThumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
{thumbnailSize}
|
{thumbnailSize}
|
||||||
publicSharedKey={sharedLink?.key}
|
publicSharedKey={sharedLink?.key}
|
||||||
|
|
|
@ -1,311 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
|
||||||
import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
|
||||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
|
||||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
|
||||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
|
||||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
|
||||||
import Star from 'svelte-material-icons/Star.svelte';
|
|
||||||
import { fade, fly } from 'svelte/transition';
|
|
||||||
import LoadingSpinner from './loading-spinner.svelte';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let asset: AssetResponseDto;
|
|
||||||
export let groupIndex = 0;
|
|
||||||
export let thumbnailSize: number | undefined = undefined;
|
|
||||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
|
||||||
export let selected = false;
|
|
||||||
export let disabled = false;
|
|
||||||
export let readonly = false;
|
|
||||||
export let publicSharedKey = '';
|
|
||||||
export let isRoundedCorner = false;
|
|
||||||
|
|
||||||
let mouseOver = false;
|
|
||||||
let playMotionVideo = false;
|
|
||||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
|
||||||
|
|
||||||
let mouseOverIcon = false;
|
|
||||||
let videoPlayerNode: HTMLVideoElement;
|
|
||||||
let isImageLoading = true;
|
|
||||||
let isThumbnailVideoPlaying = false;
|
|
||||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
|
||||||
let videoProgress = '00:00';
|
|
||||||
let videoUrl: string;
|
|
||||||
$: isPublicShared = publicSharedKey !== '';
|
|
||||||
|
|
||||||
const loadVideoData = async (isLivePhoto: boolean) => {
|
|
||||||
isThumbnailVideoPlaying = false;
|
|
||||||
|
|
||||||
if (isLivePhoto && asset.livePhotoVideoId) {
|
|
||||||
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey);
|
|
||||||
} else {
|
|
||||||
videoUrl = getFileUrl(asset.id, false, true, publicSharedKey);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVideoDurationInString = (currentTime: number) => {
|
|
||||||
const minute = Math.floor(currentTime / 60);
|
|
||||||
const second = currentTime % 60;
|
|
||||||
|
|
||||||
const minuteText = minute >= 10 ? `${minute}` : `0${minute}`;
|
|
||||||
const secondText = second >= 10 ? `${second}` : `0${second}`;
|
|
||||||
|
|
||||||
return minuteText + ':' + secondText;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseVideoDuration = (duration: string) => {
|
|
||||||
duration = duration || '0:00:00.00000';
|
|
||||||
const timePart = duration.split(':');
|
|
||||||
const hours = timePart[0];
|
|
||||||
const minutes = timePart[1];
|
|
||||||
const seconds = timePart[2];
|
|
||||||
|
|
||||||
if (hours != '0') {
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
} else {
|
|
||||||
return `${minutes}:${seconds.split('.')[0]}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSize = () => {
|
|
||||||
if (thumbnailSize) {
|
|
||||||
return `w-[${thumbnailSize}px] h-[${thumbnailSize}px]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
|
|
||||||
return 'w-[176px] h-[235px]';
|
|
||||||
} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
|
|
||||||
return 'w-[313px] h-[235px]';
|
|
||||||
} else {
|
|
||||||
return 'w-[235px] h-[235px]';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseOverThumbnail = () => {
|
|
||||||
mouseOver = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeaveThumbnail = () => {
|
|
||||||
mouseOver = false;
|
|
||||||
videoUrl = '';
|
|
||||||
|
|
||||||
clearInterval(calculateVideoDurationIntervalHandler);
|
|
||||||
|
|
||||||
isThumbnailVideoPlaying = false;
|
|
||||||
videoProgress = '00:00';
|
|
||||||
|
|
||||||
if (videoPlayerNode) {
|
|
||||||
videoPlayerNode.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCanPlay = (ev: Event) => {
|
|
||||||
const playerNode = ev.target as HTMLVideoElement;
|
|
||||||
|
|
||||||
playerNode.muted = true;
|
|
||||||
playerNode.play();
|
|
||||||
|
|
||||||
isThumbnailVideoPlaying = true;
|
|
||||||
calculateVideoDurationIntervalHandler = setInterval(() => {
|
|
||||||
videoProgress = getVideoDurationInString(Math.round(playerNode.currentTime));
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
$: getThumbnailBorderStyle = () => {
|
|
||||||
if (selected) {
|
|
||||||
return 'border-[20px] border-immich-primary/20';
|
|
||||||
} else if (disabled) {
|
|
||||||
return 'border-[20px] border-gray-300';
|
|
||||||
} else if (isRoundedCorner) {
|
|
||||||
return 'rounded-lg';
|
|
||||||
} else {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$: getOverlaySelectorIconStyle = () => {
|
|
||||||
if (selected || disabled) {
|
|
||||||
return '';
|
|
||||||
} else {
|
|
||||||
return 'bg-gradient-to-b from-gray-800/50';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const thumbnailClickedHandler = () => {
|
|
||||||
if (!disabled) {
|
|
||||||
dispatch('click', { asset });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onIconClickedHandler = (e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!disabled) {
|
|
||||||
dispatch('select', { asset });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<IntersectionObserver once={false} let:intersecting>
|
|
||||||
<div
|
|
||||||
style:width={`${thumbnailSize}px`}
|
|
||||||
style:height={`${thumbnailSize}px`}
|
|
||||||
class={`bg-gray-100 dark:bg-immich-dark-gray relative select-none ${getSize()} ${
|
|
||||||
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
|
||||||
}`}
|
|
||||||
on:mouseenter={handleMouseOverThumbnail}
|
|
||||||
on:mouseleave={handleMouseLeaveThumbnail}
|
|
||||||
on:click={thumbnailClickedHandler}
|
|
||||||
on:keydown={thumbnailClickedHandler}
|
|
||||||
>
|
|
||||||
{#if (mouseOver || selected || disabled) && !readonly}
|
|
||||||
<div
|
|
||||||
in:fade={{ duration: 200 }}
|
|
||||||
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
on:click={onIconClickedHandler}
|
|
||||||
on:mouseenter={() => (mouseOverIcon = true)}
|
|
||||||
on:mouseleave={() => (mouseOverIcon = false)}
|
|
||||||
class="inline-block"
|
|
||||||
>
|
|
||||||
{#if selected}
|
|
||||||
<CheckCircle size="24" color="#4250af" />
|
|
||||||
{:else if disabled}
|
|
||||||
<CheckCircle size="24" color="#252525" />
|
|
||||||
{:else}
|
|
||||||
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if asset.isFavorite && !isPublicShared}
|
|
||||||
<div class="w-full absolute bottom-2 left-2 z-10">
|
|
||||||
<Star size="24" color={'white'} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Playback and info -->
|
|
||||||
{#if asset.type === AssetTypeEnum.Video}
|
|
||||||
<div
|
|
||||||
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
|
|
||||||
>
|
|
||||||
{#if isThumbnailVideoPlaying}
|
|
||||||
<span in:fly={{ x: -25, duration: 500 }}>
|
|
||||||
{videoProgress}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span in:fade={{ duration: 500 }}>
|
|
||||||
{parseVideoDuration(asset.duration)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mouseOver}
|
|
||||||
{#if isThumbnailVideoPlaying}
|
|
||||||
<span in:fly={{ x: 25, duration: 500 }}>
|
|
||||||
<PauseCircleOutline size="24" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span in:fade={{ duration: 250 }}>
|
|
||||||
<LoadingSpinner />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span in:fade={{ duration: 500 }}>
|
|
||||||
<PlayCircleOutline size="24" />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
|
||||||
<div
|
|
||||||
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
in:fade={{ duration: 500 }}
|
|
||||||
on:mouseenter={() => {
|
|
||||||
playMotionVideo = true;
|
|
||||||
loadVideoData(true);
|
|
||||||
}}
|
|
||||||
on:mouseleave={() => (playMotionVideo = false)}
|
|
||||||
>
|
|
||||||
{#if playMotionVideo}
|
|
||||||
<span in:fade={{ duration: 500 }}>
|
|
||||||
<MotionPauseOutline size="24" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span in:fade={{ duration: 500 }}>
|
|
||||||
<MotionPlayOutline size="24" />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<!-- {/if} -->
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Thumbnail -->
|
|
||||||
{#if intersecting}
|
|
||||||
<img
|
|
||||||
id={asset.id}
|
|
||||||
style:width={`${thumbnailSize}px`}
|
|
||||||
style:height={`${thumbnailSize}px`}
|
|
||||||
src={`/api/asset/thumbnail/${asset.id}?format=${format}&key=${publicSharedKey}`}
|
|
||||||
alt={asset.id}
|
|
||||||
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
|
|
||||||
class:opacity-0={isImageLoading}
|
|
||||||
loading="lazy"
|
|
||||||
draggable="false"
|
|
||||||
on:load|once={() => (isImageLoading = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mouseOver && asset.type === AssetTypeEnum.Video}
|
|
||||||
<div class="absolute w-full h-full top-0" on:mouseenter={() => loadVideoData(false)}>
|
|
||||||
{#if videoUrl}
|
|
||||||
<video
|
|
||||||
muted
|
|
||||||
autoplay
|
|
||||||
preload="none"
|
|
||||||
class="h-full object-cover"
|
|
||||||
width="250px"
|
|
||||||
style:width={`${thumbnailSize}px`}
|
|
||||||
on:canplay={handleCanPlay}
|
|
||||||
bind:this={videoPlayerNode}
|
|
||||||
>
|
|
||||||
<source src={videoUrl} type="video/mp4" />
|
|
||||||
<track kind="captions" />
|
|
||||||
</video>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if playMotionVideo && asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
|
||||||
<div class="absolute w-full h-full top-0">
|
|
||||||
{#if videoUrl}
|
|
||||||
<video
|
|
||||||
muted
|
|
||||||
autoplay
|
|
||||||
preload="none"
|
|
||||||
class="h-full object-cover"
|
|
||||||
width="250px"
|
|
||||||
style:width={`${thumbnailSize}px`}
|
|
||||||
on:canplay={handleCanPlay}
|
|
||||||
bind:this={videoPlayerNode}
|
|
||||||
>
|
|
||||||
<source src={videoUrl} type="video/mp4" />
|
|
||||||
<track kind="captions" />
|
|
||||||
</video>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</IntersectionObserver>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
img {
|
|
||||||
transition: 0.2s ease all;
|
|
||||||
}
|
|
||||||
</style>
|
|
24
web/src/lib/utils/time-to-seconds.spec.ts
Normal file
24
web/src/lib/utils/time-to-seconds.spec.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { timeToSeconds } from './time-to-seconds';
|
||||||
|
|
||||||
|
describe('converting time to seconds', () => {
|
||||||
|
it('parses hh:mm:ss correctly', () => {
|
||||||
|
expect(timeToSeconds('01:02:03')).toBeCloseTo(3723);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses hh:mm:ss.SSS correctly', () => {
|
||||||
|
expect(timeToSeconds('01:02:03.456')).toBeCloseTo(3723.456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses h:m:s.S correctly', () => {
|
||||||
|
expect(timeToSeconds('1:2:3.4')).toBeCloseTo(3723.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses hhh:mm:ss.SSS correctly', () => {
|
||||||
|
expect(timeToSeconds('100:02:03.456')).toBeCloseTo(360123.456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => {
|
||||||
|
expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456);
|
||||||
|
});
|
||||||
|
});
|
13
web/src/lib/utils/time-to-seconds.ts
Normal file
13
web/src/lib/utils/time-to-seconds.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Duration } from 'luxon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert time like `01:02:03.456` to seconds.
|
||||||
|
*/
|
||||||
|
export function timeToSeconds(time: string) {
|
||||||
|
const parts = time.split(':');
|
||||||
|
parts[2] = parts[2].split('.').slice(0, 2).join('.');
|
||||||
|
|
||||||
|
const [hours, minutes, seconds] = parts.map(Number);
|
||||||
|
|
||||||
|
return Duration.fromObject({ hours, minutes, seconds }).as('seconds');
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
|
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { AssetTypeEnum, SearchExploreItem } from '@api';
|
import { AssetTypeEnum, SearchExploreItem } from '@api';
|
||||||
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
|
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
|
||||||
|
@ -49,12 +49,7 @@
|
||||||
{#each places as item}
|
{#each places as item}
|
||||||
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
|
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
|
||||||
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
||||||
<ImmichThumbnail
|
<Thumbnail thumbnailSize={156} asset={item.data} readonly />
|
||||||
isRoundedCorner={true}
|
|
||||||
thumbnailSize={156}
|
|
||||||
asset={item.data}
|
|
||||||
readonly={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
|
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
|
||||||
|
@ -76,12 +71,7 @@
|
||||||
{#each things as item}
|
{#each things as item}
|
||||||
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
|
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
|
||||||
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
||||||
<ImmichThumbnail
|
<Thumbnail thumbnailSize={156} asset={item.data} readonly />
|
||||||
isRoundedCorner={true}
|
|
||||||
thumbnailSize={156}
|
|
||||||
asset={item.data}
|
|
||||||
readonly={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
|
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
|
||||||
|
|
Loading…
Reference in a new issue