1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-04 05:50:38 +02:00

Merge branch 'main' into feat/notification-email-template

This commit is contained in:
Tim Van Onckelen
2024-11-18 11:43:00 +01:00
committed by GitHub
42 changed files with 621 additions and 389 deletions

View File

@ -98,6 +98,10 @@ SELECT * FROM "move_history";
SELECT * FROM "users"; SELECT * FROM "users";
``` ```
```sql title="Get owner info from asset ID"
SELECT "users".* FROM "users" JOIN "assets" ON "users"."id" = "assets"."ownerId" WHERE "assets"."id" = 'fa310b01-2f26-4b7a-9042-d578226e021f';
```
## System Config ## System Config
```sql title="Custom settings" ```sql title="Custom settings"

View File

@ -163,11 +163,15 @@ describe('/server', () => {
expect(body).toEqual({ expect(body).toEqual({
photos: 0, photos: 0,
usage: 0, usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [ usageByUser: [
{ {
quotaSizeInBytes: null, quotaSizeInBytes: null,
photos: 0, photos: 0,
usage: 0, usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'Immich Admin', userName: 'Immich Admin',
userId: admin.userId, userId: admin.userId,
videos: 0, videos: 0,
@ -176,6 +180,8 @@ describe('/server', () => {
quotaSizeInBytes: null, quotaSizeInBytes: null,
photos: 0, photos: 0,
usage: 0, usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'User 1', userName: 'User 1',
userId: nonAdmin.userId, userId: nonAdmin.userId,
videos: 0, videos: 0,

View File

@ -21,6 +21,7 @@ const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA); const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0); const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
const Color red400 = Color(0xFFEF5350);
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = { final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
ImmichColorPreset.indigo: ImmichTheme( ImmichColorPreset.indigo: ImmichTheme(

View File

@ -18,6 +18,9 @@ class CurrentUploadAsset {
this.iCloudAsset, this.iCloudAsset,
}); });
@pragma('vm:prefer-inline')
bool get isIcloudAsset => iCloudAsset != null && iCloudAsset!;
CurrentUploadAsset copyWith({ CurrentUploadAsset copyWith({
String? id, String? id,
DateTime? fileCreatedAt, DateTime? fileCreatedAt,

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart'; import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/services/partner.service.dart';
@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> { class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) { PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
final query = db.users.filter().isPartnerSharedWithEqualTo(true); Function eq = const ListEquality<User>().equals;
query.findAll().then((partners) => state = partners); final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
query.watch().listen((partners) => state = partners); query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
} }
Future<bool> updatePartner(User partner, {required bool inTimeline}) { Future<bool> updatePartner(User partner, {required bool inTimeline}) {
@ -31,9 +42,19 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> { class PartnerSharedByNotifier extends StateNotifier<List<User>> {
PartnerSharedByNotifier(Isar db) : super([]) { PartnerSharedByNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedByEqualTo(true); Function eq = const ListEquality<User>().equals;
query.findAll().then((partners) => state = partners); final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
streamSub = query.watch().listen((partners) => state = partners); query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
streamSub = query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
} }
late final StreamSubscription<List<User>> streamSub; late final StreamSubscription<List<User>> streamSub;

View File

@ -0,0 +1,102 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupAssetInfoTable extends ConsumerWidget {
const BackupAssetInfoTable({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final asset = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.currentUploadAsset),
)
: ref.watch(backupProvider.select((value) => value.currentUploadAsset));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: context.colorScheme.outlineVariant,
width: 1,
),
children: [
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
'backup_controller_page_filename',
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [asset.fileName, asset.fileType.toLowerCase()],
),
),
),
],
),
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_created",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [_getAssetCreationDate(asset)],
),
),
),
],
),
TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_id",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(args: [asset.id]),
),
),
],
),
],
),
);
}
@pragma('vm:prefer-inline')
String _getAssetCreationDate(CurrentUploadAsset asset) {
return DateFormat.yMMMMd().format(asset.fileCreatedAt.toLocal());
}
}

View File

@ -1,275 +1,26 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.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/widgets/backup/asset_info_table.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/widgets/backup/error_chip.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/widgets/backup/icloud_download_progress_bar.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/widgets/backup/upload_progress_bar.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/widgets/backup/upload_stats.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class CurrentUploadingAssetInfoBox extends HookConsumerWidget { class CurrentUploadingAssetInfoBox extends StatelessWidget {
const CurrentUploadingAssetInfoBox({super.key}); const CurrentUploadingAssetInfoBox({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
var isManualUpload = ref.watch(backupProvider).backupProgress == return ListTile(
BackUpProgressEnum.manualInProgress;
var asset = !isManualUpload
? ref.watch(backupProvider).currentUploadAsset
: ref.watch(manualUploadProvider).currentUploadAsset;
var uploadProgress = !isManualUpload
? ref.watch(backupProvider).progressInPercentage
: ref.watch(manualUploadProvider).progressInPercentage;
var uploadFileProgress = !isManualUpload
? ref.watch(backupProvider).progressInFileSize
: ref.watch(manualUploadProvider).progressInFileSize;
var uploadFileSpeed = !isManualUpload
? ref.watch(backupProvider).progressInFileSpeed
: ref.watch(manualUploadProvider).progressInFileSpeed;
var iCloudDownloadProgress =
ref.watch(backupProvider).iCloudDownloadProgress;
final isShowThumbnail = useState(false);
String formatUploadFileSpeed(double uploadFileSpeed) {
if (uploadFileSpeed < 1024) {
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
} else if (uploadFileSpeed < 1024 * 1024) {
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} else {
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
}
}
String getAssetCreationDate() {
return DateFormat.yMMMMd().format(
DateTime.parse(
asset.fileCreatedAt.toString(),
).toLocal(),
);
}
Widget buildErrorChip() {
return ActionChip(
avatar: Icon(
Icons.info,
color: Colors.red[400],
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: Text(
"backup_controller_page_failed",
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
fontSize: 11,
),
).tr(
args: [ref.watch(errorBackupListProvider).length.toString()],
),
backgroundColor: Colors.white,
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
);
}
Widget buildAssetInfoTable() {
return Table(
border: TableBorder.all(
color: context.colorScheme.outlineVariant,
width: 1,
),
children: [
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
'backup_controller_page_filename',
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [asset.fileName, asset.fileType.toLowerCase()],
),
),
),
],
),
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_created",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [getAssetCreationDate()],
),
),
),
],
),
TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_id",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(args: [asset.id]),
),
),
],
),
],
);
}
buildiCloudDownloadProgerssBar() {
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
"iCloud Download",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${iCloudDownloadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
],
),
);
}
return const SizedBox();
}
buildUploadProgressBar() {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (asset.iCloudAsset != null && asset.iCloudAsset!)
SizedBox(
width: 110,
child: Text(
"Immich Upload",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
),
],
),
);
}
buildUploadStats() {
return Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
uploadFileProgress,
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
Text(
formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
],
),
);
}
return FutureBuilder<Asset?>(
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
builder: (context, thumbnail) => ListTile(
isThreeLine: true, isThreeLine: true,
leading: AnimatedCrossFade( leading: Icon(
alignment: Alignment.centerLeft,
firstChild: GestureDetector(
onTap: () => isShowThumbnail.value = false,
child: thumbnail.hasData
? ClipRRect(
borderRadius: BorderRadius.circular(5),
child: ImmichThumbnail(
asset: thumbnail.data,
width: 50,
height: 50,
),
)
: const SizedBox(
width: 50,
height: 50,
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
),
),
),
secondChild: GestureDetector(
onTap: () => isShowThumbnail.value = true,
child: Icon(
Icons.image_outlined, Icons.image_outlined,
color: context.primaryColor, color: context.primaryColor,
size: 30, size: 30,
), ),
),
crossFadeState: isShowThumbnail.value
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -277,21 +28,17 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
"backup_controller_page_uploading_file_info", "backup_controller_page_uploading_file_info",
style: context.textTheme.titleSmall, style: context.textTheme.titleSmall,
).tr(), ).tr(),
if (ref.watch(errorBackupListProvider).isNotEmpty) buildErrorChip(), const BackupErrorChip(),
], ],
), ),
subtitle: Column( subtitle: Column(
children: [ children: [
if (Platform.isIOS) buildiCloudDownloadProgerssBar(), if (Platform.isIOS) const IcloudDownloadProgressBar(),
buildUploadProgressBar(), const BackupUploadProgressBar(),
buildUploadStats(), const BackupUploadStats(),
Padding( const BackupAssetInfoTable(),
padding: const EdgeInsets.only(top: 8.0),
child: buildAssetInfoTable(),
),
], ],
), ),
),
); );
} }
} }

View File

@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/error_chip_text.dart';
class BackupErrorChip extends ConsumerWidget {
const BackupErrorChip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasErrors =
ref.watch(errorBackupListProvider.select((value) => value.isNotEmpty));
if (!hasErrors) {
return const SizedBox();
}
return ActionChip(
avatar: const Icon(
Icons.info,
color: red400,
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: const BackupErrorChipText(),
backgroundColor: Colors.white,
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
);
}
}

View File

@ -0,0 +1,28 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
class BackupErrorChipText extends ConsumerWidget {
const BackupErrorChipText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(errorBackupListProvider).length;
if (count == 0) {
return const SizedBox();
}
return const Text(
"backup_controller_page_failed",
style: TextStyle(
color: red400,
fontWeight: FontWeight.bold,
fontSize: 11,
),
).tr(
args: [count.toString()],
);
}
}

View File

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class IcloudDownloadProgressBar extends ConsumerWidget {
const IcloudDownloadProgressBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final isIcloudAsset = isManualUpload
? ref.watch(
manualUploadProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
)
: ref.watch(
backupProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
);
if (!isIcloudAsset) {
return const SizedBox();
}
final iCloudDownloadProgress = ref
.watch(backupProvider.select((value) => value.iCloudDownloadProgress));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
"iCloud Download",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: iCloudDownloadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${iCloudDownloadProgress ~/ 1}%",
style: const TextStyle(fontSize: 12),
),
],
),
);
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupUploadProgressBar extends ConsumerWidget {
const BackupUploadProgressBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final isIcloudAsset = isManualUpload
? ref.watch(
manualUploadProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
)
: ref.watch(
backupProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
);
final uploadProgress = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.progressInPercentage),
)
: ref.watch(
backupProvider.select((value) => value.progressInPercentage),
);
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (isIcloudAsset)
SizedBox(
width: 110,
child: Text(
"Immich Upload",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
),
],
),
);
}
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupUploadStats extends ConsumerWidget {
const BackupUploadStats({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final uploadFileProgress = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.progressInFileSize),
)
: ref.watch(backupProvider.select((value) => value.progressInFileSize));
final uploadFileSpeed = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.progressInFileSpeed),
)
: ref.watch(
backupProvider.select((value) => value.progressInFileSpeed),
);
return Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
uploadFileProgress,
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
Text(
_formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
],
),
);
}
@pragma('vm:prefer-inline')
String _formatUploadFileSpeed(double uploadFileSpeed) {
if (uploadFileSpeed < 1024) {
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
} else if (uploadFileSpeed < 1024 * 1024) {
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} else {
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
}
}
}

View File

@ -16,6 +16,8 @@ class ServerStatsResponseDto {
this.photos = 0, this.photos = 0,
this.usage = 0, this.usage = 0,
this.usageByUser = const [], this.usageByUser = const [],
this.usagePhotos = 0,
this.usageVideos = 0,
this.videos = 0, this.videos = 0,
}); });
@ -25,6 +27,10 @@ class ServerStatsResponseDto {
List<UsageByUserDto> usageByUser; List<UsageByUserDto> usageByUser;
int usagePhotos;
int usageVideos;
int videos; int videos;
@override @override
@ -32,6 +38,8 @@ class ServerStatsResponseDto {
other.photos == photos && other.photos == photos &&
other.usage == usage && other.usage == usage &&
_deepEquality.equals(other.usageByUser, usageByUser) && _deepEquality.equals(other.usageByUser, usageByUser) &&
other.usagePhotos == usagePhotos &&
other.usageVideos == usageVideos &&
other.videos == videos; other.videos == videos;
@override @override
@ -40,16 +48,20 @@ class ServerStatsResponseDto {
(photos.hashCode) + (photos.hashCode) +
(usage.hashCode) + (usage.hashCode) +
(usageByUser.hashCode) + (usageByUser.hashCode) +
(usagePhotos.hashCode) +
(usageVideos.hashCode) +
(videos.hashCode); (videos.hashCode);
@override @override
String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, videos=$videos]'; String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, usagePhotos=$usagePhotos, usageVideos=$usageVideos, videos=$videos]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'photos'] = this.photos; json[r'photos'] = this.photos;
json[r'usage'] = this.usage; json[r'usage'] = this.usage;
json[r'usageByUser'] = this.usageByUser; json[r'usageByUser'] = this.usageByUser;
json[r'usagePhotos'] = this.usagePhotos;
json[r'usageVideos'] = this.usageVideos;
json[r'videos'] = this.videos; json[r'videos'] = this.videos;
return json; return json;
} }
@ -66,6 +78,8 @@ class ServerStatsResponseDto {
photos: mapValueOfType<int>(json, r'photos')!, photos: mapValueOfType<int>(json, r'photos')!,
usage: mapValueOfType<int>(json, r'usage')!, usage: mapValueOfType<int>(json, r'usage')!,
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']), usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']),
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
videos: mapValueOfType<int>(json, r'videos')!, videos: mapValueOfType<int>(json, r'videos')!,
); );
} }
@ -117,6 +131,8 @@ class ServerStatsResponseDto {
'photos', 'photos',
'usage', 'usage',
'usageByUser', 'usageByUser',
'usagePhotos',
'usageVideos',
'videos', 'videos',
}; };
} }

View File

@ -16,6 +16,8 @@ class UsageByUserDto {
required this.photos, required this.photos,
required this.quotaSizeInBytes, required this.quotaSizeInBytes,
required this.usage, required this.usage,
required this.usagePhotos,
required this.usageVideos,
required this.userId, required this.userId,
required this.userName, required this.userName,
required this.videos, required this.videos,
@ -27,6 +29,10 @@ class UsageByUserDto {
int usage; int usage;
int usagePhotos;
int usageVideos;
String userId; String userId;
String userName; String userName;
@ -38,6 +44,8 @@ class UsageByUserDto {
other.photos == photos && other.photos == photos &&
other.quotaSizeInBytes == quotaSizeInBytes && other.quotaSizeInBytes == quotaSizeInBytes &&
other.usage == usage && other.usage == usage &&
other.usagePhotos == usagePhotos &&
other.usageVideos == usageVideos &&
other.userId == userId && other.userId == userId &&
other.userName == userName && other.userName == userName &&
other.videos == videos; other.videos == videos;
@ -48,12 +56,14 @@ class UsageByUserDto {
(photos.hashCode) + (photos.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(usage.hashCode) + (usage.hashCode) +
(usagePhotos.hashCode) +
(usageVideos.hashCode) +
(userId.hashCode) + (userId.hashCode) +
(userName.hashCode) + (userName.hashCode) +
(videos.hashCode); (videos.hashCode);
@override @override
String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, userId=$userId, userName=$userName, videos=$videos]'; String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, usagePhotos=$usagePhotos, usageVideos=$usageVideos, userId=$userId, userName=$userName, videos=$videos]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -64,6 +74,8 @@ class UsageByUserDto {
// json[r'quotaSizeInBytes'] = null; // json[r'quotaSizeInBytes'] = null;
} }
json[r'usage'] = this.usage; json[r'usage'] = this.usage;
json[r'usagePhotos'] = this.usagePhotos;
json[r'usageVideos'] = this.usageVideos;
json[r'userId'] = this.userId; json[r'userId'] = this.userId;
json[r'userName'] = this.userName; json[r'userName'] = this.userName;
json[r'videos'] = this.videos; json[r'videos'] = this.videos;
@ -82,6 +94,8 @@ class UsageByUserDto {
photos: mapValueOfType<int>(json, r'photos')!, photos: mapValueOfType<int>(json, r'photos')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'), quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
usage: mapValueOfType<int>(json, r'usage')!, usage: mapValueOfType<int>(json, r'usage')!,
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
userId: mapValueOfType<String>(json, r'userId')!, userId: mapValueOfType<String>(json, r'userId')!,
userName: mapValueOfType<String>(json, r'userName')!, userName: mapValueOfType<String>(json, r'userName')!,
videos: mapValueOfType<int>(json, r'videos')!, videos: mapValueOfType<int>(json, r'videos')!,
@ -135,6 +149,8 @@ class UsageByUserDto {
'photos', 'photos',
'quotaSizeInBytes', 'quotaSizeInBytes',
'usage', 'usage',
'usagePhotos',
'usageVideos',
'userId', 'userId',
'userName', 'userName',
'videos', 'videos',

View File

@ -11017,7 +11017,9 @@
{ {
"photos": 1, "photos": 1,
"videos": 1, "videos": 1,
"diskUsageRaw": 1 "diskUsageRaw": 2,
"usagePhotos": 1,
"usageVideos": 1
} }
], ],
"items": { "items": {
@ -11026,6 +11028,16 @@
"title": "Array of usage for each user", "title": "Array of usage for each user",
"type": "array" "type": "array"
}, },
"usagePhotos": {
"default": 0,
"format": "int64",
"type": "integer"
},
"usageVideos": {
"default": 0,
"format": "int64",
"type": "integer"
},
"videos": { "videos": {
"default": 0, "default": 0,
"type": "integer" "type": "integer"
@ -11035,6 +11047,8 @@
"photos", "photos",
"usage", "usage",
"usageByUser", "usageByUser",
"usagePhotos",
"usageVideos",
"videos" "videos"
], ],
"type": "object" "type": "object"
@ -12614,6 +12628,14 @@
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
}, },
"usagePhotos": {
"format": "int64",
"type": "integer"
},
"usageVideos": {
"format": "int64",
"type": "integer"
},
"userId": { "userId": {
"type": "string" "type": "string"
}, },
@ -12628,6 +12650,8 @@
"photos", "photos",
"quotaSizeInBytes", "quotaSizeInBytes",
"usage", "usage",
"usagePhotos",
"usageVideos",
"userId", "userId",
"userName", "userName",
"videos" "videos"

View File

@ -976,6 +976,8 @@ export type UsageByUserDto = {
photos: number; photos: number;
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
usage: number; usage: number;
usagePhotos: number;
usageVideos: number;
userId: string; userId: string;
userName: string; userName: string;
videos: number; videos: number;
@ -984,6 +986,8 @@ export type ServerStatsResponseDto = {
photos: number; photos: number;
usage: number; usage: number;
usageByUser: UsageByUserDto[]; usageByUser: UsageByUserDto[];
usagePhotos: number;
usageVideos: number;
videos: number; videos: number;
}; };
export type ServerStorageResponseDto = { export type ServerStorageResponseDto = {

View File

@ -86,6 +86,10 @@ export class UsageByUserDto {
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
usage!: number; usage!: number;
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
usagePhotos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usageVideos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null; quotaSizeInBytes!: number | null;
} }
@ -99,6 +103,12 @@ export class ServerStatsResponseDto {
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
usage = 0; usage = 0;
@ApiProperty({ type: 'integer', format: 'int64' })
usagePhotos = 0;
@ApiProperty({ type: 'integer', format: 'int64' })
usageVideos = 0;
@ApiProperty({ @ApiProperty({
isArray: true, isArray: true,
type: UsageByUserDto, type: UsageByUserDto,
@ -107,7 +117,9 @@ export class ServerStatsResponseDto {
{ {
photos: 1, photos: 1,
videos: 1, videos: 1,
diskUsageRaw: 1, diskUsageRaw: 2,
usagePhotos: 1,
usageVideos: 1,
}, },
], ],
}) })

View File

@ -11,6 +11,8 @@ export interface UserStatsQueryResponse {
photos: number; photos: number;
videos: number; videos: number;
usage: number; usage: number;
usagePhotos: number;
usageVideos: number;
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
} }

View File

@ -140,7 +140,23 @@ SELECT
"assets"."libraryId" IS NULL "assets"."libraryId" IS NULL
), ),
0 0
) AS "usage" ) AS "usage",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
AND "assets"."type" = 'IMAGE'
),
0
) AS "usagePhotos",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
AND "assets"."type" = 'VIDEO'
),
0
) AS "usageVideos"
FROM FROM
"users" "users" "users" "users"
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id" LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"

View File

@ -108,6 +108,14 @@ export class UserRepository implements IUserRepository {
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos') .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos') .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage') .addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
.addSelect(
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`,
'usagePhotos',
)
.addSelect(
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`,
'usageVideos',
)
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes') .addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
.leftJoin('users.assets', 'assets') .leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif') .leftJoin('assets.exifInfo', 'exif')
@ -119,6 +127,8 @@ export class UserRepository implements IUserRepository {
stat.photos = Number(stat.photos); stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos); stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage); stat.usage = Number(stat.usage);
stat.usagePhotos = Number(stat.usagePhotos);
stat.usageVideos = Number(stat.usageVideos);
stat.quotaSizeInBytes = stat.quotaSizeInBytes; stat.quotaSizeInBytes = stat.quotaSizeInBytes;
} }

View File

@ -185,6 +185,8 @@ describe(ServerService.name, () => {
photos: 10, photos: 10,
videos: 11, videos: 11,
usage: 12_345, usage: 12_345,
usagePhotos: 1,
usageVideos: 11_345,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
}, },
{ {
@ -193,6 +195,8 @@ describe(ServerService.name, () => {
photos: 10, photos: 10,
videos: 20, videos: 20,
usage: 123_456, usage: 123_456,
usagePhotos: 100,
usageVideos: 23_456,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
}, },
{ {
@ -201,6 +205,8 @@ describe(ServerService.name, () => {
photos: 100, photos: 100,
videos: 0, videos: 0,
usage: 987_654, usage: 987_654,
usagePhotos: 900,
usageVideos: 87_654,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
}, },
]); ]);
@ -209,11 +215,15 @@ describe(ServerService.name, () => {
photos: 120, photos: 120,
videos: 31, videos: 31,
usage: 1_123_455, usage: 1_123_455,
usagePhotos: 1001,
usageVideos: 122_455,
usageByUser: [ usageByUser: [
{ {
photos: 10, photos: 10,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
usage: 12_345, usage: 12_345,
usagePhotos: 1,
usageVideos: 11_345,
userName: '1 User', userName: '1 User',
userId: 'user1', userId: 'user1',
videos: 11, videos: 11,
@ -222,6 +232,8 @@ describe(ServerService.name, () => {
photos: 10, photos: 10,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
usage: 123_456, usage: 123_456,
usagePhotos: 100,
usageVideos: 23_456,
userName: '2 User', userName: '2 User',
userId: 'user2', userId: 'user2',
videos: 20, videos: 20,
@ -230,6 +242,8 @@ describe(ServerService.name, () => {
photos: 100, photos: 100,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
usage: 987_654, usage: 987_654,
usagePhotos: 900,
usageVideos: 87_654,
userName: '3 User', userName: '3 User',
userId: 'user3', userId: 'user3',
videos: 0, videos: 0,

View File

@ -126,11 +126,16 @@ export class ServerService extends BaseService {
usage.photos = user.photos; usage.photos = user.photos;
usage.videos = user.videos; usage.videos = user.videos;
usage.usage = user.usage; usage.usage = user.usage;
usage.usagePhotos = user.usagePhotos;
usage.usageVideos = user.usageVideos;
usage.quotaSizeInBytes = user.quotaSizeInBytes; usage.quotaSizeInBytes = user.quotaSizeInBytes;
serverStats.photos += usage.photos; serverStats.photos += usage.photos;
serverStats.videos += usage.videos; serverStats.videos += usage.videos;
serverStats.usage += usage.usage; serverStats.usage += usage.usage;
serverStats.usagePhotos += usage.usagePhotos;
serverStats.usageVideos += usage.usageVideos;
serverStats.usageByUser.push(usage); serverStats.usageByUser.push(usage);
} }

View File

@ -16,6 +16,8 @@
photos: 0, photos: 0,
videos: 0, videos: 0,
usage: 0, usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [], usageByUser: [],
}, },
}: Props = $props(); }: Props = $props();
@ -105,8 +107,12 @@
class="flex h-[50px] w-full place-items-center text-center odd:bg-immich-gray even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50" class="flex h-[50px] w-full place-items-center text-center odd:bg-immich-gray even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50"
> >
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td> <td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td> <td class="w-1/4 text-ellipsis px-2 text-sm"
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td> >{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td
>
<td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.videos.toLocaleString($locale)} ({getByteUnitString(user.usageVideos, $locale, 0)})</td
>
<td class="w-1/4 text-ellipsis px-2 text-sm"> <td class="w-1/4 text-ellipsis px-2 text-sm">
{getByteUnitString(user.usage, $locale, 0)} {getByteUnitString(user.usage, $locale, 0)}
{#if user.quotaSizeInBytes} {#if user.quotaSizeInBytes}

View File

@ -58,7 +58,6 @@
const handleToggle = (user: UserResponseDto) => { const handleToggle = (user: UserResponseDto) => {
if (Object.keys(selectedUsers).includes(user.id)) { if (Object.keys(selectedUsers).includes(user.id)) {
delete selectedUsers[user.id]; delete selectedUsers[user.id];
selectedUsers = selectedUsers;
} else { } else {
selectedUsers[user.id] = { user, role: AlbumUserRole.Editor }; selectedUsers[user.id] = { user, role: AlbumUserRole.Editor };
} }
@ -67,7 +66,6 @@
const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => { const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => {
if (role === 'none') { if (role === 'none') {
delete selectedUsers[user.id]; delete selectedUsers[user.id];
selectedUsers = selectedUsers;
} else { } else {
selectedUsers[user.id].role = role; selectedUsers[user.id].role = role;
} }

View File

@ -110,7 +110,6 @@
try { try {
await deleteActivity({ id: reaction.id }); await deleteActivity({ id: reaction.id });
reactions.splice(index, 1); reactions.splice(index, 1);
reactions = reactions;
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) { if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
onDeleteLike(); onDeleteLike();
} else { } else {
@ -143,8 +142,6 @@
message = ''; message = '';
onAddComment(); onAddComment();
// Re-render the activity feed
reactions = reactions;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_add_comment')); handleError(error, $t('errors.unable_to_add_comment'));
} finally { } finally {

View File

@ -43,10 +43,10 @@
import DetailPanel from './detail-panel.svelte'; import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte'; import EditorPanel from './editor/editor-panel.svelte';
import PanoramaViewer from './panorama-viewer.svelte';
import PhotoViewer from './photo-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte'; import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte'; import VideoViewer from './video-wrapper-viewer.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
interface Props { interface Props {
assetStore?: AssetStore | null; assetStore?: AssetStore | null;
@ -512,7 +512,7 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase() .toLowerCase()
.endsWith('.insp'))} .endsWith('.insp'))}
<PanoramaViewer {asset} /> <ImagePanoramaViewer {asset} />
{:else if isShowEditor && selectedEditType === 'crop'} {:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} /> <CropArea {asset} />
{:else} {:else}

View File

@ -0,0 +1,32 @@
<script lang="ts">
import { getAssetOriginalUrl, getKey } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
const { asset }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: getKey() });
return URL.createObjectURL(data);
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
panorama={data}
originalImageUrl={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
/>
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
</div>

View File

@ -1,54 +0,0 @@
<script lang="ts">
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getKey } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, AssetTypeEnum, viewAsset, type AssetResponseDto } from '@immich/sdk';
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
}
let { asset }: Props = $props();
const photoSphereConfigs =
asset.type === AssetTypeEnum.Video
? ([
import('@photo-sphere-viewer/equirectangular-video-adapter').then(
({ EquirectangularVideoAdapter }) => EquirectangularVideoAdapter,
),
import('@photo-sphere-viewer/video-plugin').then(({ VideoPlugin }) => [VideoPlugin]),
true,
import('@photo-sphere-viewer/video-plugin/index.css'),
] as [PromiseLike<AdapterConstructor>, Promise<PluginConstructor[]>, true, unknown])
: ([undefined, [], false] as [undefined, [], false]);
const originalImageUrl =
asset.type === AssetTypeEnum.Image && isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : null;
const loadAssetData = async () => {
if (asset.type === AssetTypeEnum.Video) {
return { source: getAssetOriginalUrl(asset.id) };
}
if (originalImageUrl && $alwaysLoadOriginalFile) {
return getAssetOriginalUrl(asset.id);
}
const data = await viewAsset({ id: asset.id, size: AssetMediaSize.Preview, key: getKey() });
const url = URL.createObjectURL(data);
return url;
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data -->
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
<LoadingSpinner />
{:then [data, module, adapter, plugins, navbar]}
<module.default panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} {originalImageUrl} />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
</div>

View File

@ -12,7 +12,7 @@
interface Props { interface Props {
panorama: string | { source: string }; panorama: string | { source: string };
originalImageUrl: string | null; originalImageUrl?: string;
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean; navbar?: boolean;

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { getAssetOriginalUrl } from '$lib/utils';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
interface Props {
assetId: string;
}
const { assetId }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
import('@photo-sphere-viewer/equirectangular-video-adapter').then((module) => module.EquirectangularVideoAdapter),
import('@photo-sphere-viewer/video-plugin').then((module) => module.VideoPlugin),
import('@photo-sphere-viewer/video-plugin/index.css'),
]);
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer panorama={{ source: getAssetOriginalUrl(assetId) }} plugins={[videoPlugin]} {adapter} navbar />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
</div>

View File

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AssetTypeEnum } from '@immich/sdk';
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte'; import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte'; import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
interface Props { interface Props {
assetId: string; assetId: string;
@ -30,7 +29,7 @@
</script> </script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR} {#if projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} /> <VideoPanoramaViewer {assetId} />
{:else} {:else}
<VideoNativeViewer <VideoNativeViewer
{loopVideo} {loopVideo}

View File

@ -90,7 +90,6 @@
for (const person of people) { for (const person of people) {
person.isHidden = personIsHidden[person.id]; person.isHidden = personIsHidden[person.id];
} }
people = people;
onClose(); onClose();
} catch (error) { } catch (error) {

View File

@ -101,15 +101,9 @@
const handleReset = (id: string) => { const handleReset = (id: string) => {
if (selectedPersonToReassign[id]) { if (selectedPersonToReassign[id]) {
delete selectedPersonToReassign[id]; delete selectedPersonToReassign[id];
// trigger reactivity
selectedPersonToReassign = selectedPersonToReassign;
} }
if (selectedPersonToCreate[id]) { if (selectedPersonToCreate[id]) {
delete selectedPersonToCreate[id]; delete selectedPersonToCreate[id];
// trigger reactivity
selectedPersonToCreate = selectedPersonToCreate;
} }
}; };

View File

@ -35,12 +35,10 @@
} }
selectedIds.add(option.value); selectedIds.add(option.value);
selectedIds = selectedIds;
}; };
const handleRemove = (tag: string) => { const handleRemove = (tag: string) => {
selectedIds.delete(tag); selectedIds.delete(tag);
selectedIds = selectedIds;
}; };
const onsubmit = (event: Event) => { const onsubmit = (event: Event) => {

View File

@ -112,7 +112,6 @@
assets.findIndex((a) => a.id === action.asset.id), assets.findIndex((a) => a.id === action.asset.id),
1, 1,
); );
assets = assets;
if (assets.length === 0) { if (assets.length === 0) {
await goto(AppRoute.PHOTOS); await goto(AppRoute.PHOTOS);
} else if (currentViewAssetIndex === assets.length) { } else if (currentViewAssetIndex === assets.length) {

View File

@ -7,7 +7,7 @@
export type SearchFilter = { export type SearchFilter = {
query: string; query: string;
queryType: 'smart' | 'metadata'; queryType: 'smart' | 'metadata';
personIds: Set<string>; personIds: SvelteSet<string>;
location: SearchLocationFilter; location: SearchLocationFilter;
camera: SearchCameraFilter; camera: SearchCameraFilter;
date: SearchDateFilter; date: SearchDateFilter;

View File

@ -9,9 +9,10 @@
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import type { SvelteSet } from 'svelte/reactivity';
interface Props { interface Props {
selectedPeople: Set<string>; selectedPeople: SvelteSet<string>;
} }
let { selectedPeople = $bindable() }: Props = $props(); let { selectedPeople = $bindable() }: Props = $props();
@ -43,7 +44,6 @@
} else { } else {
selectedPeople.add(id); selectedPeople.add(id);
} }
selectedPeople = selectedPeople;
} }
const filterPeople = (list: PersonResponseDto[], name: string) => { const filterPeople = (list: PersonResponseDto[], name: string) => {

View File

@ -117,7 +117,6 @@
await updatePartner({ id: partner.user.id, updatePartnerDto: { inTimeline } }); await updatePartner({ id: partner.user.id, updatePartnerDto: { inTimeline } });
partner.inTimeline = inTimeline; partner.inTimeline = inTimeline;
partners = partners;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_update_timeline_display_status')); handleError(error, $t('errors.unable_to_update_timeline_display_status'));
} }

View File

@ -35,7 +35,6 @@
} }
selectedAssetIds.add(suggestedAsset.id); selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
}); });
onDestroy(() => { onDestroy(() => {
@ -48,13 +47,10 @@
} else { } else {
selectedAssetIds.add(asset.id); selectedAssetIds.add(asset.id);
} }
selectedAssetIds = selectedAssetIds;
}; };
const onSelectNone = () => { const onSelectNone = () => {
selectedAssetIds.clear(); selectedAssetIds.clear();
selectedAssetIds = selectedAssetIds;
}; };
const onSelectAll = () => { const onSelectAll = () => {

View File

@ -35,8 +35,6 @@
person.updatedAt = Date.now().toString(); person.updatedAt = Date.now().toString();
} }
}); });
// trigger reactivity
people = people;
}); });
}); });
</script> </script>

View File

@ -74,9 +74,6 @@
person.updatedAt = new Date().toISOString(); person.updatedAt = new Date().toISOString();
} }
} }
// trigger reactivity
people = people;
}); });
}); });
@ -146,9 +143,6 @@
message: $t('change_name_successfully'), message: $t('change_name_successfully'),
type: NotificationType.Info, type: NotificationType.Info,
}); });
// trigger reactivity
people = people;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_save_name')); handleError(error, $t('errors.unable_to_save_name'));
} }

View File

@ -156,8 +156,6 @@
searchResultAlbums.push(...albums.items); searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items); searchResultAssets.push(...assets.items);
searchResultAlbums = searchResultAlbums;
searchResultAssets = searchResultAssets;
nextPage = assets.nextPage ? Number(assets.nextPage) : null; nextPage = assets.nextPage ? Number(assets.nextPage) : null;
} catch (error) { } catch (error) {