_createCoordinatesUri(ExifInfo? exifInfo) async {
diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart
index ec44aaef1..4f6e2706d 100644
--- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart
+++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart
@@ -100,7 +100,7 @@ class ControlBottomAppBar extends ConsumerWidget {
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
- if (!hasRemote)
+ if (hasLocal)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "Upload",
diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart
index b1800e8e6..d41022a29 100644
--- a/mobile/lib/modules/home/views/home_page.dart
+++ b/mobile/lib/modules/home/views/home_page.dart
@@ -169,9 +169,10 @@ class HomePage extends HookConsumerWidget {
processing.value = true;
selectionEnabledHook.value = false;
try {
- ref
- .read(manualUploadProvider.notifier)
- .uploadAssets(context, selection.value);
+ ref.read(manualUploadProvider.notifier).uploadAssets(
+ context,
+ selection.value.where((a) => a.storage == AssetState.local),
+ );
} finally {
processing.value = false;
}
diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart
index 568e4ce13..a61fd2c28 100644
--- a/mobile/lib/shared/models/exif_info.dart
+++ b/mobile/lib/shared/models/exif_info.dart
@@ -8,6 +8,8 @@ part 'exif_info.g.dart';
class ExifInfo {
Id? id;
int? fileSize;
+ DateTime? dateTimeOriginal;
+ String? timeZone;
String? make;
String? model;
String? lens;
@@ -47,6 +49,8 @@ class ExifInfo {
ExifInfo.fromDto(ExifResponseDto dto)
: fileSize = dto.fileSizeInByte,
+ dateTimeOriginal = dto.dateTimeOriginal,
+ timeZone = dto.timeZone,
make = dto.make,
model = dto.model,
lens = dto.lensModel,
@@ -64,6 +68,8 @@ class ExifInfo {
ExifInfo({
this.id,
this.fileSize,
+ this.dateTimeOriginal,
+ this.timeZone,
this.make,
this.model,
this.lens,
@@ -82,6 +88,8 @@ class ExifInfo {
ExifInfo copyWith({
Id? id,
int? fileSize,
+ DateTime? dateTimeOriginal,
+ String? timeZone,
String? make,
String? model,
String? lens,
@@ -99,6 +107,8 @@ class ExifInfo {
ExifInfo(
id: id ?? this.id,
fileSize: fileSize ?? this.fileSize,
+ dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
+ timeZone: timeZone ?? this.timeZone,
make: make ?? this.make,
model: model ?? this.model,
lens: lens ?? this.lens,
@@ -119,6 +129,8 @@ class ExifInfo {
if (other is! ExifInfo) return false;
return id == other.id &&
fileSize == other.fileSize &&
+ dateTimeOriginal == other.dateTimeOriginal &&
+ timeZone == other.timeZone &&
make == other.make &&
model == other.model &&
lens == other.lens &&
@@ -139,6 +151,8 @@ class ExifInfo {
int get hashCode =>
id.hashCode ^
fileSize.hashCode ^
+ dateTimeOriginal.hashCode ^
+ timeZone.hashCode ^
make.hashCode ^
model.hashCode ^
lens.hashCode ^
diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart
index 9122942bd..138e386c7 100644
--- a/mobile/lib/shared/models/exif_info.g.dart
+++ b/mobile/lib/shared/models/exif_info.g.dart
@@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema(
name: r'country',
type: IsarType.string,
),
- r'description': PropertySchema(
+ r'dateTimeOriginal': PropertySchema(
id: 2,
+ name: r'dateTimeOriginal',
+ type: IsarType.dateTime,
+ ),
+ r'description': PropertySchema(
+ id: 3,
name: r'description',
type: IsarType.string,
),
r'exposureSeconds': PropertySchema(
- id: 3,
+ id: 4,
name: r'exposureSeconds',
type: IsarType.float,
),
r'f': PropertySchema(
- id: 4,
+ id: 5,
name: r'f',
type: IsarType.float,
),
r'fileSize': PropertySchema(
- id: 5,
+ id: 6,
name: r'fileSize',
type: IsarType.long,
),
r'iso': PropertySchema(
- id: 6,
+ id: 7,
name: r'iso',
type: IsarType.int,
),
r'lat': PropertySchema(
- id: 7,
+ id: 8,
name: r'lat',
type: IsarType.float,
),
r'lens': PropertySchema(
- id: 8,
+ id: 9,
name: r'lens',
type: IsarType.string,
),
r'long': PropertySchema(
- id: 9,
+ id: 10,
name: r'long',
type: IsarType.float,
),
r'make': PropertySchema(
- id: 10,
+ id: 11,
name: r'make',
type: IsarType.string,
),
r'mm': PropertySchema(
- id: 11,
+ id: 12,
name: r'mm',
type: IsarType.float,
),
r'model': PropertySchema(
- id: 12,
+ id: 13,
name: r'model',
type: IsarType.string,
),
r'state': PropertySchema(
- id: 13,
+ id: 14,
name: r'state',
type: IsarType.string,
+ ),
+ r'timeZone': PropertySchema(
+ id: 15,
+ name: r'timeZone',
+ type: IsarType.string,
)
},
estimateSize: _exifInfoEstimateSize,
@@ -150,6 +160,12 @@ int _exifInfoEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
+ {
+ final value = object.timeZone;
+ if (value != null) {
+ bytesCount += 3 + value.length * 3;
+ }
+ }
return bytesCount;
}
@@ -161,18 +177,20 @@ void _exifInfoSerialize(
) {
writer.writeString(offsets[0], object.city);
writer.writeString(offsets[1], object.country);
- writer.writeString(offsets[2], object.description);
- writer.writeFloat(offsets[3], object.exposureSeconds);
- writer.writeFloat(offsets[4], object.f);
- writer.writeLong(offsets[5], object.fileSize);
- writer.writeInt(offsets[6], object.iso);
- writer.writeFloat(offsets[7], object.lat);
- writer.writeString(offsets[8], object.lens);
- writer.writeFloat(offsets[9], object.long);
- writer.writeString(offsets[10], object.make);
- writer.writeFloat(offsets[11], object.mm);
- writer.writeString(offsets[12], object.model);
- writer.writeString(offsets[13], object.state);
+ writer.writeDateTime(offsets[2], object.dateTimeOriginal);
+ writer.writeString(offsets[3], object.description);
+ writer.writeFloat(offsets[4], object.exposureSeconds);
+ writer.writeFloat(offsets[5], object.f);
+ writer.writeLong(offsets[6], object.fileSize);
+ writer.writeInt(offsets[7], object.iso);
+ writer.writeFloat(offsets[8], object.lat);
+ writer.writeString(offsets[9], object.lens);
+ writer.writeFloat(offsets[10], object.long);
+ writer.writeString(offsets[11], object.make);
+ writer.writeFloat(offsets[12], object.mm);
+ writer.writeString(offsets[13], object.model);
+ writer.writeString(offsets[14], object.state);
+ writer.writeString(offsets[15], object.timeZone);
}
ExifInfo _exifInfoDeserialize(
@@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize(
final object = ExifInfo(
city: reader.readStringOrNull(offsets[0]),
country: reader.readStringOrNull(offsets[1]),
- description: reader.readStringOrNull(offsets[2]),
- exposureSeconds: reader.readFloatOrNull(offsets[3]),
- f: reader.readFloatOrNull(offsets[4]),
- fileSize: reader.readLongOrNull(offsets[5]),
+ dateTimeOriginal: reader.readDateTimeOrNull(offsets[2]),
+ description: reader.readStringOrNull(offsets[3]),
+ exposureSeconds: reader.readFloatOrNull(offsets[4]),
+ f: reader.readFloatOrNull(offsets[5]),
+ fileSize: reader.readLongOrNull(offsets[6]),
id: id,
- iso: reader.readIntOrNull(offsets[6]),
- lat: reader.readFloatOrNull(offsets[7]),
- lens: reader.readStringOrNull(offsets[8]),
- long: reader.readFloatOrNull(offsets[9]),
- make: reader.readStringOrNull(offsets[10]),
- mm: reader.readFloatOrNull(offsets[11]),
- model: reader.readStringOrNull(offsets[12]),
- state: reader.readStringOrNull(offsets[13]),
+ iso: reader.readIntOrNull(offsets[7]),
+ lat: reader.readFloatOrNull(offsets[8]),
+ lens: reader.readStringOrNull(offsets[9]),
+ long: reader.readFloatOrNull(offsets[10]),
+ make: reader.readStringOrNull(offsets[11]),
+ mm: reader.readFloatOrNull(offsets[12]),
+ model: reader.readStringOrNull(offsets[13]),
+ state: reader.readStringOrNull(offsets[14]),
+ timeZone: reader.readStringOrNull(offsets[15]),
);
return object;
}
@@ -213,29 +233,33 @@ P _exifInfoDeserializeProp(
case 1:
return (reader.readStringOrNull(offset)) as P;
case 2:
- return (reader.readStringOrNull(offset)) as P;
+ return (reader.readDateTimeOrNull(offset)) as P;
case 3:
- return (reader.readFloatOrNull(offset)) as P;
+ return (reader.readStringOrNull(offset)) as P;
case 4:
return (reader.readFloatOrNull(offset)) as P;
case 5:
- return (reader.readLongOrNull(offset)) as P;
+ return (reader.readFloatOrNull(offset)) as P;
case 6:
- return (reader.readIntOrNull(offset)) as P;
+ return (reader.readLongOrNull(offset)) as P;
case 7:
- return (reader.readFloatOrNull(offset)) as P;
+ return (reader.readIntOrNull(offset)) as P;
case 8:
- return (reader.readStringOrNull(offset)) as P;
+ return (reader.readFloatOrNull(offset)) as P;
case 9:
- return (reader.readFloatOrNull(offset)) as P;
+ return (reader.readStringOrNull(offset)) as P;
case 10:
- return (reader.readStringOrNull(offset)) as P;
- case 11:
return (reader.readFloatOrNull(offset)) as P;
- case 12:
+ case 11:
return (reader.readStringOrNull(offset)) as P;
+ case 12:
+ return (reader.readFloatOrNull(offset)) as P;
case 13:
return (reader.readStringOrNull(offset)) as P;
+ case 14:
+ return (reader.readStringOrNull(offset)) as P;
+ case 15:
+ return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
@@ -622,6 +646,80 @@ extension ExifInfoQueryFilter
});
}
+ QueryBuilder
+ dateTimeOriginalIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'dateTimeOriginal',
+ ));
+ });
+ }
+
+ QueryBuilder
+ dateTimeOriginalIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'dateTimeOriginal',
+ ));
+ });
+ }
+
+ QueryBuilder
+ dateTimeOriginalEqualTo(DateTime? value) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'dateTimeOriginal',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder
+ dateTimeOriginalGreaterThan(
+ DateTime? value, {
+ bool include = false,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'dateTimeOriginal',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder
+ dateTimeOriginalLessThan(
+ DateTime? value, {
+ bool include = false,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'dateTimeOriginal',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder
+ dateTimeOriginalBetween(
+ DateTime? lower,
+ DateTime? upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'dateTimeOriginal',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ ));
+ });
+ }
+
QueryBuilder descriptionIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter
));
});
}
+
+ QueryBuilder timeZoneIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'timeZone',
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'timeZone',
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneEqualTo(
+ String? value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'timeZone',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneGreaterThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'timeZone',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneLessThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'timeZone',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneBetween(
+ String? lower,
+ String? upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'timeZone',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneStartsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.startsWith(
+ property: r'timeZone',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneEndsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.endsWith(
+ property: r'timeZone',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneContains(
+ String value,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.contains(
+ property: r'timeZone',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneMatches(
+ String pattern,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.matches(
+ property: r'timeZone',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'timeZone',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder timeZoneIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ property: r'timeZone',
+ value: '',
+ ));
+ });
+ }
}
extension ExifInfoQueryObject
@@ -1989,6 +2233,18 @@ extension ExifInfoQuerySortBy on QueryBuilder {
});
}
+ QueryBuilder sortByDateTimeOriginal() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'dateTimeOriginal', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByDateTimeOriginalDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'dateTimeOriginal', Sort.desc);
+ });
+ }
+
QueryBuilder sortByDescription() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.asc);
@@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder {
return query.addSortBy(r'state', Sort.desc);
});
}
+
+ QueryBuilder sortByTimeZone() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'timeZone', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByTimeZoneDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'timeZone', Sort.desc);
+ });
+ }
}
extension ExifInfoQuerySortThenBy
@@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy
});
}
+ QueryBuilder thenByDateTimeOriginal() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'dateTimeOriginal', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByDateTimeOriginalDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'dateTimeOriginal', Sort.desc);
+ });
+ }
+
QueryBuilder thenByDescription() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'description', Sort.asc);
@@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy
return query.addSortBy(r'state', Sort.desc);
});
}
+
+ QueryBuilder thenByTimeZone() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'timeZone', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByTimeZoneDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'timeZone', Sort.desc);
+ });
+ }
}
extension ExifInfoQueryWhereDistinct
@@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct
});
}
+ QueryBuilder distinctByDateTimeOriginal() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'dateTimeOriginal');
+ });
+ }
+
QueryBuilder distinctByDescription(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct
return query.addDistinctBy(r'state', caseSensitive: caseSensitive);
});
}
+
+ QueryBuilder distinctByTimeZone(
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'timeZone', caseSensitive: caseSensitive);
+ });
+ }
}
extension ExifInfoQueryProperty
@@ -2431,6 +2736,13 @@ extension ExifInfoQueryProperty
});
}
+ QueryBuilder
+ dateTimeOriginalProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'dateTimeOriginal');
+ });
+ }
+
QueryBuilder descriptionProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'description');
@@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty
return query.addPropertyName(r'state');
});
}
+
+ QueryBuilder timeZoneProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'timeZone');
+ });
+ }
}
diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart
index b17fce86d..ede113837 100644
--- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart
+++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart
@@ -194,6 +194,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
Navigator.of(context).pop();
launchUrl(
Uri.parse('https://immich.app'),
+ mode: LaunchMode.externalApplication,
);
},
child: Text(
@@ -213,6 +214,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
Navigator.of(context).pop();
launchUrl(
Uri.parse('https://github.com/immich-app/immich'),
+ mode: LaunchMode.externalApplication,
);
},
child: Text(
diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart
index 8ef3c09b5..fa4a73536 100644
--- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart
+++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart
@@ -182,19 +182,36 @@ class AppBarServerInfo extends HookConsumerWidget {
child: Container(
width: 200,
padding: const EdgeInsets.only(right: 10.0),
- child: Text(
- getServerUrl() ?? '--',
- style: TextStyle(
- fontSize: 11,
- color: Theme.of(context)
- .textTheme
- .labelSmall
- ?.color
- ?.withOpacity(0.5),
- fontWeight: FontWeight.bold,
- overflow: TextOverflow.ellipsis,
+ child: Tooltip(
+ verticalOffset: 0,
+ decoration: BoxDecoration(
+ color:
+ Theme.of(context).primaryColor.withOpacity(0.9),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ textStyle: TextStyle(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.black
+ : Colors.white,
+ fontWeight: FontWeight.bold,
+ ),
+ message: getServerUrl() ?? '--',
+ preferBelow: false,
+ triggerMode: TooltipTriggerMode.tap,
+ child: Text(
+ getServerUrl() ?? '--',
+ style: TextStyle(
+ fontSize: 11,
+ color: Theme.of(context)
+ .textTheme
+ .labelSmall
+ ?.color
+ ?.withOpacity(0.5),
+ fontWeight: FontWeight.bold,
+ overflow: TextOverflow.ellipsis,
+ ),
+ textAlign: TextAlign.end,
),
- textAlign: TextAlign.end,
),
),
),
diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart
index ad8195354..3510931f6 100644
--- a/mobile/lib/shared/ui/immich_app_bar.dart
+++ b/mobile/lib/shared/ui/immich_app_bar.dart
@@ -31,7 +31,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
const widgetSize = 30.0;
- buildProfilePhoto() {
+ buildProfileIndicator() {
return InkWell(
onTap: () => showDialog(
context: context,
@@ -39,37 +39,33 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
builder: (ctx) => const ImmichAppBarDialog(),
),
borderRadius: BorderRadius.circular(12),
- child: authState.profileImagePath.isEmpty || user == null
- ? const Icon(
- Icons.face_outlined,
- size: widgetSize,
- )
- : UserCircleAvatar(
- radius: 15,
- size: 27,
- user: user,
- ),
- );
- }
-
- buildProfileIndicator() {
- return Badge(
- label: Container(
- decoration: BoxDecoration(
- color: Colors.black,
- borderRadius: BorderRadius.circular(widgetSize / 2),
- ),
- child: const Icon(
- Icons.info,
- color: Color.fromARGB(255, 243, 188, 106),
- size: widgetSize / 2,
+ child: Badge(
+ label: Container(
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(widgetSize / 2),
+ ),
+ child: const Icon(
+ Icons.info,
+ color: Color.fromARGB(255, 243, 188, 106),
+ size: widgetSize / 2,
+ ),
),
+ backgroundColor: Colors.transparent,
+ alignment: Alignment.bottomRight,
+ isLabelVisible: serverInfoState.isVersionMismatch,
+ offset: const Offset(2, 2),
+ child: authState.profileImagePath.isEmpty || user == null
+ ? const Icon(
+ Icons.face_outlined,
+ size: widgetSize,
+ )
+ : UserCircleAvatar(
+ radius: 15,
+ size: 27,
+ user: user,
+ ),
),
- backgroundColor: Colors.transparent,
- alignment: Alignment.bottomRight,
- isLabelVisible: serverInfoState.isVersionMismatch,
- offset: const Offset(2, 2),
- child: buildProfilePhoto(),
);
}
diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md
index 8ae91efc2..1af3f1f49 100644
--- a/mobile/openapi/doc/ActivityApi.md
+++ b/mobile/openapi/doc/ActivityApi.md
@@ -125,7 +125,7 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getActivities**
-> List getActivities(albumId, assetId, type)
+> List getActivities(albumId, assetId, type, userId)
@@ -151,9 +151,10 @@ final api_instance = ActivityApi();
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final type = ; // ReactionType |
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
- final result = api_instance.getActivities(albumId, assetId, type);
+ final result = api_instance.getActivities(albumId, assetId, type, userId);
print(result);
} catch (e) {
print('Exception when calling ActivityApi->getActivities: $e\n');
@@ -167,6 +168,7 @@ Name | Type | Description | Notes
**albumId** | **String**| |
**assetId** | **String**| | [optional]
**type** | [**ReactionType**](.md)| | [optional]
+ **userId** | **String**| | [optional]
### Return type
diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart
index fa04c68b5..458538a5d 100644
--- a/mobile/openapi/lib/api/activity_api.dart
+++ b/mobile/openapi/lib/api/activity_api.dart
@@ -111,7 +111,9 @@ class ActivityApi {
/// * [String] assetId:
///
/// * [ReactionType] type:
- Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async {
+ ///
+ /// * [String] userId:
+ Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
// ignore: prefer_const_declarations
final path = r'/activity';
@@ -129,6 +131,9 @@ class ActivityApi {
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
+ if (userId != null) {
+ queryParams.addAll(_queryParams('', 'userId', userId));
+ }
const contentTypes = [];
@@ -151,8 +156,10 @@ class ActivityApi {
/// * [String] assetId:
///
/// * [ReactionType] type:
- Future?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async {
- final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, );
+ ///
+ /// * [String] userId:
+ Future?> getActivities(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
+ final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, userId: userId, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart
index 401264c2b..9b6fe1a6c 100644
--- a/mobile/openapi/test/activity_api_test.dart
+++ b/mobile/openapi/test/activity_api_test.dart
@@ -27,7 +27,7 @@ void main() {
// TODO
});
- //Future> getActivities(String albumId, { String assetId, ReactionType type }) async
+ //Future> getActivities(String albumId, { String assetId, ReactionType type, String userId }) async
test('test getActivities', () async {
// TODO
});
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 73bfd11b0..3fc34f62e 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -1401,7 +1401,7 @@ packages:
source: hosted
version: "2.1.3"
timezone:
- dependency: transitive
+ dependency: "direct main"
description:
name: timezone
sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index a38797830..6d5aa735d 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
-version: 1.83.0+107
+version: 1.84.0+108
isar_version: &isar_version 3.1.0+1
environment:
@@ -52,6 +52,7 @@ dependencies:
crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
wakelock_plus: ^1.1.1
flutter_local_notifications: ^15.1.0+1
+ timezone: ^0.9.2
openapi:
path: openapi
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index 3a02cd1d0..bb4900716 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -30,6 +30,15 @@
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
+ },
+ {
+ "name": "userId",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
}
],
"responses": {
@@ -5635,7 +5644,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
- "version": "1.83.0",
+ "version": "1.84.0",
"contact": {}
},
"tags": [],
diff --git a/server/package-lock.json b/server/package-lock.json
index 7cebecaf8..6eb1fb7f3 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich",
- "version": "1.83.0",
+ "version": "1.84.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
- "version": "1.83.0",
+ "version": "1.84.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.22.11",
diff --git a/server/package.json b/server/package.json
index 182d7f7d5..f073c738a 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "immich",
- "version": "1.83.0",
+ "version": "1.84.0",
"description": "",
"author": "",
"private": true,
diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts
index 894c9b29c..e1a163b81 100644
--- a/server/src/domain/activity/activity.dto.ts
+++ b/server/src/domain/activity/activity.dto.ts
@@ -38,6 +38,9 @@ export class ActivitySearchDto extends ActivityDto {
@Optional()
@ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
type?: ReactionType;
+
+ @ValidateUUID({ optional: true })
+ userId?: string;
}
const isComment = (dto: ActivityCreateDto) => dto.type === 'comment';
diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts
index e5af6169a..bacdab317 100644
--- a/server/src/domain/activity/activity.service.ts
+++ b/server/src/domain/activity/activity.service.ts
@@ -28,6 +28,7 @@ export class ActivityService {
async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
const activities = await this.repository.search({
+ userId: dto.userId,
albumId: dto.albumId,
assetId: dto.assetId,
isLiked: dto.type && dto.type === ReactionType.LIKE,
diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts
index 0f5e01320..bacd4bfe6 100644
--- a/server/src/domain/asset/response-dto/asset-response.dto.ts
+++ b/server/src/domain/asset/response-dto/asset-response.dto.ts
@@ -98,7 +98,14 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
tags: entity.tags?.map(mapTag),
people: entity.faces
?.map(mapFace)
- .filter((person): person is PersonResponseDto => person !== null && !person.isHidden),
+ .filter((person): person is PersonResponseDto => person !== null && !person.isHidden)
+ .reduce((people, person) => {
+ const existingPerson = people.find((p) => p.id === person.id);
+ if (!existingPerson) {
+ people.push(person);
+ }
+ return people;
+ }, [] as PersonResponseDto[]),
checksum: entity.checksum.toString('base64'),
stackParentId: entity.stackParentId,
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts
index e0e239f6d..9dac7e604 100644
--- a/server/src/immich/api-v1/asset/asset-repository.ts
+++ b/server/src/immich/api-v1/asset/asset-repository.ts
@@ -109,7 +109,9 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
- stack: true,
+ stack: {
+ exifInfo: true,
+ },
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts
index c488630ec..5cc86fc6a 100644
--- a/server/test/e2e/activity.e2e-spec.ts
+++ b/server/test/e2e/activity.e2e-spec.ts
@@ -134,6 +134,29 @@ describe(`${ActivityController.name} (e2e)`, () => {
expect(body[0]).toEqual(reaction);
});
+ it('should filter by userId', async () => {
+ const [reaction] = await Promise.all([
+ api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
+ ]);
+
+ const response1 = await request(server)
+ .get('/activity')
+ .query({ albumId: album.id, userId: uuidStub.notFound })
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(response1.status).toEqual(200);
+ expect(response1.body.length).toBe(0);
+
+ const response2 = await request(server)
+ .get('/activity')
+ .query({ albumId: album.id, userId: admin.userId })
+ .set('Authorization', `Bearer ${admin.accessToken}`);
+
+ expect(response2.status).toEqual(200);
+ expect(response2.body.length).toBe(1);
+ expect(response2.body[0]).toEqual(reaction);
+ });
+
it('should filter by assetId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts
index 814dc8f92..9a534e7bd 100644
--- a/web/src/api/open-api/base.ts
+++ b/web/src/api/open-api/base.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts
index 7d762d6ac..8997b2d52 100644
--- a/web/src/api/open-api/common.ts
+++ b/web/src/api/open-api/common.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts
index 3ace8a93f..8058881d1 100644
--- a/web/src/api/open-api/configuration.ts
+++ b/web/src/api/open-api/configuration.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts
index 62d163db6..d0651f28a 100644
--- a/web/src/api/open-api/index.ts
+++ b/web/src/api/open-api/index.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index ebffe28d2..20d4820a7 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
@@ -32,7 +32,7 @@
export let showDownloadButton: boolean;
export let showDetailButton: boolean;
export let showSlideshow = false;
- export let hasStackChildern = false;
+ export let hasStackChildren = false;
$: isOwner = asset.ownerId === $page.data.user?.id;
@@ -176,7 +176,7 @@
/>
onMenuClick('asProfileImage')} text="As profile picture" />
- {#if hasStackChildern}
+ {#if hasStackChildren}
onMenuClick('unstack')} text="Un-Stack" />
{/if}
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 8ececaf1d..83da35e70 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -29,25 +29,24 @@
import { browser } from '$app/environment';
import { handleError } from '$lib/utils/handle-error';
import type { AssetStore } from '$lib/stores/assets.store';
- import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
- import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
+ import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+ import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { featureFlags } from '$lib/stores/server-config.store';
import {
- mdiChevronLeft,
mdiHeartOutline,
mdiHeart,
mdiCommentOutline,
+ mdiChevronLeft,
mdiChevronRight,
- mdiClose,
mdiImageBrokenVariant,
- mdiPause,
- mdiPlay,
} from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
import ActivityViewer from './activity-viewer.svelte';
+ import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
+ import SlideshowBar from './slideshow-bar.svelte';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@@ -62,6 +61,14 @@
let reactions: ActivityResponseDto[] = [];
+ const { setAssetId } = assetViewingStore;
+ const {
+ restartProgress: restartSlideshowProgress,
+ stopProgress: stopSlideshowProgress,
+ slideshowShuffle,
+ slideshowState,
+ } = slideshowStore;
+
const dispatch = createEventDispatcher<{
archived: AssetResponseDto;
unarchived: AssetResponseDto;
@@ -82,6 +89,8 @@
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let shouldShowDetailButton = asset.hasMetadata;
let canCopyImagesToClipboard: boolean;
+ let slideshowStateUnsubscribe: () => void;
+ let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined;
let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null;
@@ -123,15 +132,18 @@
};
const getFavorite = async () => {
- if (album) {
+ if (album && user) {
try {
const { data } = await api.activityApi.getActivities({
+ userId: user.id,
assetId: asset.id,
albumId: album.id,
type: ReactionType.Like,
});
if (data.length > 0) {
isLiked = data[0];
+ } else {
+ isLiked = null;
}
} catch (error) {
handleError(error, "Can't get Favorite");
@@ -161,6 +173,23 @@
onMount(async () => {
document.addEventListener('keydown', onKeyboardPress);
+ slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
+ if (value === SlideshowState.PlaySlideshow) {
+ slideshowHistory.reset();
+ slideshowHistory.queue(asset.id);
+ handlePlaySlideshow();
+ } else if (value === SlideshowState.StopSlideshow) {
+ handleStopSlideshow();
+ }
+ });
+
+ shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => {
+ if (value) {
+ slideshowHistory.reset();
+ slideshowHistory.queue(asset.id);
+ }
+ });
+
if (!sharedLink) {
await getAllAlbums();
}
@@ -184,6 +213,14 @@
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
+
+ if (slideshowStateUnsubscribe) {
+ slideshowStateUnsubscribe();
+ }
+
+ if (shuffleSlideshowUnsubscribe) {
+ shuffleSlideshowUnsubscribe();
+ }
});
$: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
@@ -262,11 +299,31 @@
const closeViewer = () => dispatch('close');
+ const navigateAssetRandom = async () => {
+ if (!assetStore) {
+ return;
+ }
+
+ const asset = await assetStore.getRandomAsset();
+ if (!asset) {
+ return;
+ }
+
+ slideshowHistory.queue(asset.id);
+
+ setAssetId(asset.id);
+ $restartSlideshowProgress = true;
+ };
+
const navigateAssetForward = async (e?: Event) => {
- if (isSlideshowMode && assetStore && progressBar) {
+ if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
+ return slideshowHistory.next() || navigateAssetRandom();
+ }
+
+ if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) {
const hasNext = await assetStore.getNextAssetId(asset.id);
if (hasNext) {
- progressBar.restart(true);
+ $restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
@@ -277,8 +334,13 @@
};
const navigateAssetBackward = (e?: Event) => {
- if (isSlideshowMode && progressBar) {
- progressBar.restart(true);
+ if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
+ slideshowHistory.previous();
+ return;
+ }
+
+ if ($slideshowState === SlideshowState.PlaySlideshow) {
+ $restartSlideshowProgress = true;
}
e?.stopPropagation();
@@ -426,19 +488,21 @@
* Slide show mode
*/
- let isSlideshowMode = false;
let assetViewerHtmlElement: HTMLElement;
- let progressBar: ProgressBar;
- let progressBarStatus: ProgressBarStatus;
+
+ const slideshowHistory = new SlideshowHistory((assetId: string) => {
+ setAssetId(assetId);
+ $restartSlideshowProgress = true;
+ });
const handleVideoStarted = () => {
- if (isSlideshowMode) {
- progressBar.restart(false);
+ if ($slideshowState === SlideshowState.PlaySlideshow) {
+ $stopSlideshowProgress = true;
}
};
const handleVideoEnded = async () => {
- if (isSlideshowMode) {
+ if ($slideshowState === SlideshowState.PlaySlideshow) {
await navigateAssetForward();
}
};
@@ -448,19 +512,20 @@
await assetViewerHtmlElement.requestFullscreen();
} catch (error) {
console.error('Error entering fullscreen', error);
- } finally {
- isSlideshowMode = true;
+ $slideshowState = SlideshowState.StopSlideshow;
}
};
const handleStopSlideshow = async () => {
try {
- await document.exitFullscreen();
+ if (document.fullscreenElement) {
+ await document.exitFullscreen();
+ }
} catch (error) {
console.error('Error exiting fullscreen', error);
} finally {
- isSlideshowMode = false;
- progressBar.restart(false);
+ $stopSlideshowProgress = true;
+ $slideshowState = SlideshowState.None;
}
};
@@ -484,7 +549,7 @@
}
asset.stackCount = 0;
asset.stack = [];
- assetStore?.updateAsset(asset);
+ assetStore?.updateAsset(asset, true);
dispatch('unstack');
notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
@@ -497,31 +562,10 @@
-
+ {/if}
- {#if !isSlideshowMode && showNavigation}
-
+ {#if $slideshowState === SlideshowState.None && showNavigation}
+
{/if}
+
-
+
+ {#if $slideshowState != SlideshowState.None}
+
+ ($slideshowState = SlideshowState.StopSlideshow)}
+ />
+
+ {/if}
+
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
@@ -602,7 +657,7 @@
on:onVideoStarted={handleVideoStarted}
/>
{/if}
- {#if isShared}
+ {#if $slideshowState === SlideshowState.None && isShared}
-
-
- {#if !isSlideshowMode && showNavigation}
-
+ {#if $slideshowState === SlideshowState.None && showNavigation}
+
{/if}
- {#if !isSlideshowMode && $isShowDetail}
+ {#if $slideshowState === SlideshowState.None && $isShowDetail}
+ import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
+ import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
+ import { slideshowStore } from '$lib/stores/slideshow.store';
+ import { createEventDispatcher, onDestroy, onMount } from 'svelte';
+ import {
+ mdiChevronLeft,
+ mdiChevronRight,
+ mdiClose,
+ mdiPause,
+ mdiPlay,
+ mdiShuffle,
+ mdiShuffleDisabled,
+ } from '@mdi/js';
+
+ const { slideshowShuffle } = slideshowStore;
+ const { restartProgress, stopProgress } = slideshowStore;
+
+ let progressBarStatus: ProgressBarStatus;
+ let progressBar: ProgressBar;
+
+ let unsubscribeRestart: () => void;
+ let unsubscribeStop: () => void;
+
+ const dispatch = createEventDispatcher<{
+ next: void;
+ prev: void;
+ close: void;
+ }>();
+
+ onMount(() => {
+ unsubscribeRestart = restartProgress.subscribe((value) => {
+ if (value) {
+ progressBar.restart(value);
+ }
+ });
+
+ unsubscribeStop = stopProgress.subscribe((value) => {
+ if (value) {
+ progressBar.restart(false);
+ }
+ });
+ });
+
+ onDestroy(() => {
+ if (unsubscribeRestart) {
+ unsubscribeRestart();
+ }
+
+ if (unsubscribeStop) {
+ unsubscribeStop();
+ }
+ });
+
+
+
+ dispatch('close')} title="Exit Slideshow" />
+ {#if $slideshowShuffle}
+ ($slideshowShuffle = false)} title="Shuffle" />
+ {:else}
+ ($slideshowShuffle = true)} title="No shuffle" />
+ {/if}
+ (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
+ title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
+ />
+ dispatch('prev')} title="Previous" />
+ dispatch('next')} title="Next" />
+
+
+ dispatch('next')}
+ duration={5000}
+/>
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte
index 95770a5c4..f80bce9b9 100644
--- a/web/src/lib/components/layouts/user-page-layout.svelte
+++ b/web/src/lib/components/layouts/user-page-layout.svelte
@@ -12,7 +12,7 @@
export let scrollbar = true;
export let admin = false;
- $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden pl-4';
+ $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden';
$: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
index 750b3247b..222654c59 100644
--- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
+++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
@@ -21,11 +21,27 @@
$: hoverY = height - windowHeight + clientY;
$: scrollY = toScrollY(timelineY);
- $: segments = $assetStore.buckets.map((bucket) => ({
- count: bucket.assets.length,
- height: toScrollY(bucket.bucketHeight),
- timeGroup: bucket.bucketDate,
- }));
+
+ class Segment {
+ public count;
+ public height;
+ public timeGroup;
+
+ constructor({ count = 0, height = 0, timeGroup = '' }) {
+ this.count = count;
+ this.height = height;
+ this.timeGroup = timeGroup;
+ }
+ }
+
+ $: segments = $assetStore.buckets.map(
+ (bucket) =>
+ new Segment({
+ count: bucket.assets.length,
+ height: toScrollY(bucket.bucketHeight),
+ timeGroup: bucket.bucketDate,
+ }),
+ );
const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
@@ -51,6 +67,23 @@
isAnimating = false;
});
};
+
+ const prevYearSegmentHeight = (segments: Segment[], index: number) => {
+ var prevYear = null;
+ var height = 0;
+ for (var i = index; i >= 0; i--) {
+ const curr = segments[i];
+ const currYear = fromLocalDateTime(curr.timeGroup).year;
+ if (prevYear && prevYear != currYear) {
+ break;
+ }
+
+ height += curr.height;
+ prevYear = currYear;
+ }
+
+ return height;
+ };
@@ -96,6 +129,7 @@
{@const date = fromLocalDateTime(segment.timeGroup)}
{@const year = date.year}
{@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
+ {@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year}
(hoverLabel = label)}
>
- {#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
+ {#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
{year}
diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts
index 431019f4a..7488ddf6a 100644
--- a/web/src/lib/stores/assets.store.ts
+++ b/web/src/lib/stores/assets.store.ts
@@ -304,7 +304,20 @@ export class AssetStore {
return this.assetToBucket[assetId]?.bucketIndex ?? null;
}
- updateAsset(_asset: AssetResponseDto) {
+ async getRandomAsset(): Promise
{
+ const bucket = this.buckets[Math.floor(Math.random() * this.buckets.length)] || null;
+ if (!bucket) {
+ return null;
+ }
+
+ if (bucket.assets.length === 0) {
+ await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
+ }
+
+ return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null;
+ }
+
+ updateAsset(_asset: AssetResponseDto, recalculate = false) {
const asset = this.assets.find((asset) => asset.id === _asset.id);
if (!asset) {
return;
@@ -312,7 +325,7 @@ export class AssetStore {
Object.assign(asset, _asset);
- this.emit(false);
+ this.emit(recalculate);
}
removeAssets(ids: string[]) {
diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts
new file mode 100644
index 000000000..45b570c2e
--- /dev/null
+++ b/web/src/lib/stores/slideshow.store.ts
@@ -0,0 +1,45 @@
+import { persisted } from 'svelte-local-storage-store';
+import { writable } from 'svelte/store';
+
+export enum SlideshowState {
+ PlaySlideshow = 'play-slideshow',
+ StopSlideshow = 'stop-slideshow',
+ None = 'none',
+}
+
+function createSlideshowStore() {
+ const restartState = writable(false);
+ const stopState = writable(false);
+
+ const slideshowShuffle = persisted('slideshow-shuffle', true);
+ const slideshowState = writable(SlideshowState.None);
+
+ return {
+ restartProgress: {
+ subscribe: restartState.subscribe,
+ set: (value: boolean) => {
+ // Trigger an action whenever the restartProgress is set to true. Automatically
+ // reset the restart state after that
+ if (value) {
+ restartState.set(true);
+ restartState.set(false);
+ }
+ },
+ },
+ stopProgress: {
+ subscribe: stopState.subscribe,
+ set: (value: boolean) => {
+ // Trigger an action whenever the stopProgress is set to true. Automatically
+ // reset the stop state after that
+ if (value) {
+ stopState.set(true);
+ stopState.set(false);
+ }
+ },
+ },
+ slideshowShuffle,
+ slideshowState,
+ };
+}
+
+export const slideshowStore = createSlideshowStore();
diff --git a/web/src/lib/utils/slideshow-history.ts b/web/src/lib/utils/slideshow-history.ts
new file mode 100644
index 000000000..8b34359d0
--- /dev/null
+++ b/web/src/lib/utils/slideshow-history.ts
@@ -0,0 +1,40 @@
+export class SlideshowHistory {
+ private history: string[] = [];
+ private index = 0;
+
+ constructor(private onChange: (assetId: string) => void) {}
+
+ reset() {
+ this.history = [];
+ this.index = 0;
+ }
+
+ queue(assetId: string) {
+ this.history.push(assetId);
+
+ // If we were at the end of the slideshow history, move the index to the new end
+ if (this.index === this.history.length - 2) {
+ this.index++;
+ }
+ }
+
+ next(): boolean {
+ if (this.index === this.history.length - 1) {
+ return false;
+ }
+
+ this.index++;
+ this.onChange(this.history[this.index]);
+ return true;
+ }
+
+ previous(): boolean {
+ if (this.index === 0) {
+ return false;
+ }
+
+ this.index--;
+ this.onChange(this.history[this.index]);
+ return true;
+ }
+}
diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte
index 1c0db32c9..0199f1cae 100644
--- a/web/src/routes/(user)/albums/[albumId]/+page.svelte
+++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte
@@ -29,6 +29,7 @@
import { AppRoute, dateFormats } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+ import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { AssetStore } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import { downloadArchive } from '$lib/utils/asset-utils';
@@ -52,7 +53,8 @@
export let data: PageData;
- let { isViewing: showAssetViewer } = assetViewingStore;
+ let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
+ let { slideshowState, slideshowShuffle } = slideshowStore;
let album = data.album;
$: album = data.album;
@@ -108,6 +110,14 @@
}
});
+ const handleStartSlideshow = async () => {
+ const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
+ if (asset) {
+ setAssetId(asset.id);
+ $slideshowState = SlideshowState.PlaySlideshow;
+ }
+ };
+
const handleEscape = () => {
if (viewMode === ViewMode.SELECT_USERS) {
viewMode = ViewMode.VIEW;
@@ -365,6 +375,9 @@
{#if viewMode === ViewMode.ALBUM_OPTIONS}
+ {#if album.assetCount !== 0}
+
+ {/if}
(viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
{/if}
diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte
index 5eaeb237d..5926f4b6a 100644
--- a/web/src/routes/(user)/trash/+page.svelte
+++ b/web/src/routes/(user)/trash/+page.svelte
@@ -87,7 +87,7 @@
-
+
Trashed items will be permanently deleted after {$serverConfig.trashDays} days.