Compare commits

..

10 Commits

Author SHA1 Message Date
github-actions
50d7956c07 chore: version v2.4.1 2025-12-19 15:03:03 +00:00
Théo
22d3fd3b92 fix(docs): add & fix missing alt text to store badge images (#24637)
* Fix email footer: add missing alt text to store badge images

* fix: apply consistent formatting using Prettier

---------

Co-authored-by: divulgacheur <contact@theopeltier.me>
2025-12-19 09:00:31 -06:00
Luis Nachtigall
a469e86b32 fix(web): search-bar usability improvements (#24705)
* fix(search): improve search type dropdown accessibility and focus management

* fix(search): fix search options button accessibility position in search bar

* fix(search): removed unnecessary selection logic
2025-12-19 14:59:41 +00:00
Timon
138c9232df chore: update ApiKeySecretModal to use monospace font (#24690)
style(web): update ApiKeySecretModal to use monospace font for readonly textarea
2025-12-19 08:58:49 -06:00
Timon
2e1f8625ec fix(web): timezone dropdown showing "No results" when seconds are set to 00 (#24662)
* Fix timezone dropdown showing "No results" when seconds are set to 00

* remove comments

* add test for #23615
2025-12-19 08:58:17 -06:00
Daniel Dietzler
f7cbb7417c fix: dateTimeOriginal timezone updates (#24712) 2025-12-19 08:42:44 -06:00
Alex
125de91c71 fix: merged video in On This Device played with incorrect dimension (#24656)
* fix: merged video in On This Device played with incorrect dimension

* chore: pr feedback
2025-12-18 20:59:58 -06:00
Timon
c9b58f5893 fix(web): auto-start slideshow when confirming settings modal (#24629)
feat(web): auto-start slideshow when confirming settings modal
2025-12-18 21:58:22 +00:00
Timon
640fd7308b fix(mobile): infinite loading screen when hiding UI in map viewer on iOS (#24563)
* fix with logging

* remove logging

* analyze
2025-12-18 21:07:58 +00:00
shenlong
557a79f747 chore(mobile): log failures from share upload intent (#24680)
chore: log failures from share intent upload

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-18 14:30:55 -06:00
28 changed files with 355 additions and 203 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.104", "version": "2.2.105",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View File

@@ -1,4 +1,8 @@
[ [
{
"label": "v2.4.1",
"url": "https://docs.v2.4.1.archive.immich.app"
},
{ {
"label": "v2.4.0", "label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app" "url": "https://docs.v2.4.0.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.4.0", "version": "2.4.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.4.0" version = "2.4.1"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0" requires-python = ">=3.10,<4.0"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3029, "android.injected.version.code" => 3030,
"android.injected.version.name" => "2.4.0", "android.injected.version.name" => "2.4.1",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService { class AssetService {
final RemoteAssetRepository _remoteAssetRepository; final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
@@ -58,44 +60,48 @@ class AssetService {
} }
Future<double> getAspectRatio(BaseAsset asset) async { Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped; final dimension = asset is LocalAsset
double? width; ? await _getLocalAssetDimensions(asset)
double? height; : await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) { if (dimension.width == null || dimension.height == null || dimension.height == 0) {
final exif = await getExif(asset); return 1.0;
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
} }
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) { if (width == null || height == null) {
if (asset.hasRemote) { final fetched = await _localAssetRepository.get(asset.id);
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id; width = fetched?.width?.toDouble();
final remoteAsset = await _remoteAssetRepository.get(id); height = fetched?.height?.toDouble();
width = remoteAsset?.width?.toDouble(); orientation = fetched?.orientation ?? 0;
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
} }
final orientedWidth = isFlipped ? height : width; // On Android, local assets need orientation correction for 90°/270° rotations
final orientedHeight = isFlipped ? width : height; // On iOS, the Photos framework pre-corrects dimensions
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return orientedWidth / orientedHeight; return (width: width, height: height, isFlipped: isFlipped);
} }
return 1.0; Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
}
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
} }
Future<List<(String, String)>> getPlaces(String userId) { Future<List<(String, String)>> getPlaces(String userId) {

View File

@@ -12,6 +12,8 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -114,6 +116,14 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return; return;
} }
// When the AssetViewer is open, the DriftMap route stays alive in the background.
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider);
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
return;
}
final bounds = await controller.getVisibleRegion(); final bounds = await controller.getVisibleRegion();
unawaited( unawaited(
_reloadMutex.run(() async { _reloadMutex.run(() async {

View File

@@ -1,45 +1,27 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
class TimelineRow extends MultiChildRenderObjectWidget { class FixedTimelineRow extends MultiChildRenderObjectWidget {
final double height; final double dimension;
final List<double> widths;
final double spacing; final double spacing;
final TextDirection textDirection; final TextDirection textDirection;
const TimelineRow({ const FixedTimelineRow({
super.key, super.key,
required this.height, required this.dimension,
required this.widths,
required this.spacing, required this.spacing,
required this.textDirection, required this.textDirection,
required super.children, required super.children,
}); });
factory TimelineRow.fixed({
required double dimension,
required double spacing,
required TextDirection textDirection,
required List<Widget> children,
}) => TimelineRow(
height: dimension,
widths: List.filled(children.length, dimension),
spacing: spacing,
textDirection: textDirection,
children: children,
);
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection); return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
} }
@override @override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.height = height; renderObject.dimension = dimension;
renderObject.widths = widths;
renderObject.spacing = spacing; renderObject.spacing = spacing;
renderObject.textDirection = textDirection; renderObject.textDirection = textDirection;
} }
@@ -47,8 +29,7 @@ class TimelineRow extends MultiChildRenderObjectWidget {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DoubleProperty('height', height)); properties.add(DoubleProperty('dimension', dimension));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
} }
@@ -62,32 +43,21 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> { RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({ RenderFixedRow({
List<RenderBox>? children, List<RenderBox>? children,
required double height, required double dimension,
required List<double> widths,
required double spacing, required double spacing,
required TextDirection textDirection, required TextDirection textDirection,
}) : _height = height, }) : _dimension = dimension,
_widths = widths,
_spacing = spacing, _spacing = spacing,
_textDirection = textDirection { _textDirection = textDirection {
addAll(children); addAll(children);
} }
double get height => _height; double get dimension => _dimension;
double _height; double _dimension;
set height(double value) { set dimension(double value) {
if (_height == value) return; if (_dimension == value) return;
_height = value; _dimension = value;
markNeedsLayout();
}
List<double> get widths => _widths;
List<double> _widths;
set widths(List<double> value) {
if (listEquals(_widths, value)) return;
_widths = value;
markNeedsLayout(); markNeedsLayout();
} }
@@ -116,7 +86,7 @@ class RenderFixedRow extends RenderBox
} }
} }
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1)); double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
@override @override
double computeMinIntrinsicWidth(double height) => intrinsicWidth; double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@@ -125,10 +95,10 @@ class RenderFixedRow extends RenderBox
double computeMaxIntrinsicWidth(double height) => intrinsicWidth; double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override @override
double computeMinIntrinsicHeight(double width) => height; double computeMinIntrinsicHeight(double width) => dimension;
@override @override
double computeMaxIntrinsicHeight(double width) => height; double computeMaxIntrinsicHeight(double width) => dimension;
@override @override
double? computeDistanceToActualBaseline(TextBaseline baseline) { double? computeDistanceToActualBaseline(TextBaseline baseline) {
@@ -148,8 +118,7 @@ class RenderFixedRow extends RenderBox
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DoubleProperty('height', height)); properties.add(DoubleProperty('dimension', dimension));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
} }
@@ -162,25 +131,19 @@ class RenderFixedRow extends RenderBox
return; return;
} }
// Use the entire width of the parent for the row. // Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, height); size = Size(constraints.maxWidth, dimension);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
final flipMainAxis = textDirection == TextDirection.rtl; final flipMainAxis = textDirection == TextDirection.rtl;
int childIndex = 0; Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0; final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
// Layout each child horizontally. // Layout each child horizontally.
while (child != null && childIndex < widths.length) { while (child != null) {
final width = widths[childIndex];
final childConstraints = BoxConstraints.tight(Size(width, height));
child.layout(childConstraints, parentUsesSize: false); child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData; final childParentData = child.parentData! as _RowParentData;
childParentData.offset = Offset(currentX, 0); childParentData.offset = offset;
offset += Offset(dx, 0);
child = childParentData.nextSibling; child = childParentData.nextSibling;
childIndex++;
if (child != null && childIndex < widths.length) {
final nextWidth = widths[childIndex];
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
}
} }
} }
} }

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -79,7 +78,6 @@ class FixedSegment extends Segment {
assetCount: numberOfAssets, assetCount: numberOfAssets,
tileHeight: tileHeight, tileHeight: tileHeight,
spacing: spacing, spacing: spacing,
columnCount: columnCount,
); );
} }
} }
@@ -89,32 +87,24 @@ class _FixedSegmentRow extends ConsumerWidget {
final int assetCount; final int assetCount;
final double tileHeight; final double tileHeight;
final double spacing; final double spacing;
final int columnCount;
const _FixedSegmentRow({ const _FixedSegmentRow({
required this.assetIndex, required this.assetIndex,
required this.assetCount, required this.assetCount,
required this.tileHeight, required this.tileHeight,
required this.spacing, required this.spacing,
required this.columnCount,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= 3;
if (isScrubbing) { if (isScrubbing) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
if (timelineService.hasRange(assetIndex, assetCount)) { if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow( return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
context,
timelineService.getAssets(assetIndex, assetCount),
timelineService,
isDynamicLayout,
);
} }
return FutureBuilder<List<BaseAsset>>( return FutureBuilder<List<BaseAsset>>(
@@ -123,7 +113,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout); return _buildAssetRow(context, snapshot.requireData, timelineService);
}, },
); );
} }
@@ -132,13 +122,12 @@ class _FixedSegmentRow extends ConsumerWidget {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing); return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
} }
Widget _buildAssetRow( Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
BuildContext context, return FixedTimelineRow(
List<BaseAsset> assets, dimension: tileHeight,
TimelineService timelineService, spacing: spacing,
bool isDynamicLayout, textDirection: Directionality.of(context),
) { children: [
final children = [
for (int i = 0; i < assets.length; i++) for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper( TimelineAssetIndexWrapper(
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
@@ -149,41 +138,7 @@ class _FixedSegmentRow extends ConsumerWidget {
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
), ),
), ),
]; ],
final widths = List.filled(assets.length, tileHeight);
if (isDynamicLayout) {
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / assets.length;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize to get width distribution
final sum = arConfiguration.sum;
int index = 0;
for (final ratio in arConfiguration) {
// Distribute the available width proportionally based on aspect ratio configuration
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
}
}
return TimelineDragRegion(
child: TimelineRow(
height: tileHeight,
widths: widths,
spacing: spacing,
textDirection: Directionality.of(context),
children: children,
),
); );
} }
} }

View File

@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
Size size = kTimelineFixedTileExtent, Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing, double spacing = kTimelineSpacing,
}) => RepaintBoundary( }) => RepaintBoundary(
child: TimelineRow.fixed( child: FixedTimelineRow(
dimension: size.height, dimension: size.height,
spacing: spacing, spacing: spacing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>( final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -25,6 +26,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router; final AppRouter router;
final UploadService _uploadService; final UploadService _uploadService;
final ShareIntentService _shareIntentService; final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) { ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
_uploadService.taskStatusStream.listen(_updateUploadStatus); _uploadService.taskStatusStream.listen(_updateUploadStatus);
@@ -86,6 +88,21 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
for (final attachment in state) for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment, if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
]; ];
if (task.status == TaskStatus.failed) {
String? error;
final exception = task.exception;
if (exception != null && exception is TaskHttpException) {
final message = tryJsonDecode(exception.description)?['message'] as String?;
if (message != null) {
final responseCode = exception.httpResponseCode;
error = "${exception.exceptionType}, response code $responseCode: $message";
}
}
error ??= task.exception?.toString();
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
}
} }
void _taskProgressCallback(TaskProgressUpdate update) { void _taskProgressCallback(TaskProgressUpdate update) {

View File

@@ -1,7 +1,6 @@
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:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -21,7 +20,6 @@ class LayoutSettings extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SettingsSubTitle(title: "asset_list_layout_sub_title".tr()), SettingsSubTitle(title: "asset_list_layout_sub_title".tr()),
if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useDynamicLayout, valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".tr(), title: "asset_list_layout_settings_dynamic_layout_title".tr(),

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.4.0 - API version: 2.4.1
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.4.0+3029 version: 2.4.1+3030
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'

View File

@@ -87,6 +87,25 @@ void main() {
verify(() => mockLocalAssetRepository.get('local-1')).called(1); verify(() => mockLocalAssetRepository.get('local-1')).called(1);
}); });
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async { test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,7 +131,9 @@ void main() {
expect(result, 1.0); expect(result, 1.0);
}); });
test('handles local asset with remoteId and uses exif from remote', () async { test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset( final localAsset = TestUtils.createLocalAsset(
id: 'local-1', id: 'local-1',
remoteId: 'remote-1', remoteId: 'remote-1',
@@ -121,9 +142,24 @@ void main() {
orientation: 0, orientation: 0,
); );
final exif = const ExifInfo(orientation: '6'); final result = await sut.getAspectRatio(localAsset);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset); final result = await sut.getAspectRatio(localAsset);

View File

@@ -14268,7 +14268,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.4.0", "version": "2.4.1",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.4.0", "version": "2.4.1",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.4.0 * 2.4.1
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.4.0", "version": "2.4.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -7,14 +7,22 @@ export const ImmichFooter = () => (
<Column align="center" className="w-6/12 sm:w-full"> <Column align="center" className="w-6/12 sm:w-full">
<div> <div>
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain"> <Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain">
<Img className="max-w-full" src={`https://immich.app/img/google-play-badge.png`} /> <Img
alt="Get it on Google Play"
className="max-w-full"
src={`https://immich.app/img/google-play-badge.png`}
/>
</Link> </Link>
</div> </div>
</Column> </Column>
<Column align="center" className="w-6/12 sm:w-full"> <Column align="center" className="w-6/12 sm:w-full">
<div className="h-full p-6"> <div className="h-full p-6">
<Link href="https://apps.apple.com/sg/app/immich/id1613945652"> <Link href="https://apps.apple.com/sg/app/immich/id1613945652">
<Img src={`https://immich.app/img/ios-app-store-badge.png`} alt="Immich" className="max-w-full" /> <Img
alt="Download on the App Store"
className="max-w-full"
src={`https://immich.app/img/ios-app-store-badge.png`}
/>
</Link> </Link>
</div> </div>
</Column> </Column>

View File

@@ -144,14 +144,28 @@ export class AssetService extends BaseService {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids }); await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined); const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined);
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined); const exifDto = _.omitBy(
{
latitude,
longitude,
rating,
description,
dateTimeOriginal,
},
_.isUndefined,
);
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
if (Object.keys(exifDto).length > 0) { if (Object.keys(exifDto).length > 0) {
await this.assetRepository.updateAllExif(ids, exifDto); await this.assetRepository.updateAllExif(ids, exifDto);
} }
if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) { if (
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone); (dateTimeRelative !== undefined && dateTimeRelative !== 0) ||
timeZone !== undefined ||
extractedTimeZone?.type === 'fixed'
) {
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name);
} }
if (Object.keys(assetDto).length > 0) { if (Object.keys(assetDto).length > 0) {
@@ -436,7 +450,19 @@ export class AssetService extends BaseService {
rating?: number; rating?: number;
}) { }) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
const writes = _.omitBy(
{
description,
dateTimeOriginal,
timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined,
latitude,
longitude,
rating,
},
_.isUndefined,
);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif(
updateLockedColumns({ updateLockedColumns({

View File

@@ -285,7 +285,7 @@ describe(AssetService.name, () => {
.where('assetId', '=', asset.id) .where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(), .executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null }); ).resolves.toEqual({ lockedProperties: null });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' });
await expect( await expect(
ctx.database ctx.database
@@ -296,7 +296,38 @@ describe(AssetService.name, () => {
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }), exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
}),
);
});
it('should update dateTimeOriginal with time zone', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
}), }),
); );
}); });
@@ -329,4 +360,65 @@ describe(AssetService.name, () => {
); );
}); });
}); });
it('should update dateTimeOriginal', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
}),
);
});
it('should update dateTimeOriginal with time zone', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
}),
);
});
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.4.0", "version": "2.4.1",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -308,18 +308,6 @@
/> />
</div> </div>
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
{#if searchStore.isSearchEnabled} {#if searchStore.isSearchEnabled}
<div <div
id={searchTypeId} id={searchTypeId}
@@ -327,7 +315,7 @@
class:max-md:hidden={value} class:max-md:hidden={value}
class:end-28={value.length > 0} class:end-28={value.length > 0}
> >
<div class="relative"> <div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
<Button <Button
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer" class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
onclick={toggleSearchTypeDropdown} onclick={toggleSearchTypeDropdown}
@@ -340,11 +328,11 @@
{#if showSearchTypeDropdown} {#if showSearchTypeDropdown}
<div <div
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999" class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
> >
{#each searchTypes as searchType (searchType.value)} {#each searchTypes as searchType (searchType.value)}
<button <button
type="button" type="button"
tabindex="0"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}" {currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
onclick={() => selectSearchType(searchType.value)} onclick={() => selectSearchType(searchType.value)}
@@ -358,6 +346,18 @@
</div> </div>
{/if} {/if}
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
<IconButton
aria-label={$t('show_search_options')}
shape="round"
icon={mdiTune}
onclick={onFilterClick}
size="medium"
color="secondary"
variant="ghost"
/>
</div>
{#if showClearIcon} {#if showClearIcon}
<div class="absolute inset-y-0 end-0 flex items-center pe-2"> <div class="absolute inset-y-0 end-0 flex items-center pe-2">
<IconButton <IconButton

View File

@@ -15,7 +15,7 @@
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small"> <Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
<ModalBody> <ModalBody>
<Text size="small" class="mb-4">{$t('api_key_description')}</Text> <Text size="small" class="mb-4">{$t('api_key_description')}</Text>
<Textarea bind:value={secret} readonly /> <Textarea bind:value={secret} readonly class="font-mono" />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>

View File

@@ -71,6 +71,34 @@ describe('DateSelectionModal component', () => {
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
test('does not fall back to UTC when datetime-local value has no seconds', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00' } });
await fireEvent.blur(getDateInput());
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
await fireEvent.focus(getTimeZoneInput());
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
});
test('does not fall back to UTC when datetime-local value has no milliseconds', async () => {
render(AssetSelectionChangeDateModal, {
props: { initialDate, initialTimeZone, assets: [], onClose },
});
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00:00' } });
await fireEvent.blur(getDateInput());
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
await fireEvent.focus(getTimeZoneInput());
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
});
describe('when date is in daylight saving time', () => { describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01'); const dstDate = DateTime.fromISO('2024-07-01');

View File

@@ -14,7 +14,7 @@
} from '@mdi/js'; } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte'; import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store';
const { const {
slideshowDelay, slideshowDelay,
@@ -23,6 +23,7 @@
slideshowLook, slideshowLook,
slideshowTransition, slideshowTransition,
slideshowAutoplay, slideshowAutoplay,
slideshowState,
} = slideshowStore; } = slideshowStore;
interface Props { interface Props {
@@ -69,6 +70,7 @@
$slideshowLook = tempSlideshowLook; $slideshowLook = tempSlideshowLook;
$slideshowTransition = tempSlideshowTransition; $slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay; $slideshowAutoplay = tempSlideshowAutoplay;
$slideshowState = SlideshowState.PlaySlideshow;
onClose(); onClose();
}; };
</script> </script>

View File

@@ -75,8 +75,15 @@ function zoneOptionForDate(zone: string, date: string) {
// Ignore milliseconds: // Ignore milliseconds:
// - milliseconds are not relevant for TZ calculations // - milliseconds are not relevant for TZ calculations
// - browsers strip insignificant .000 making string comparison with milliseconds more fragile. // - browsers strip insignificant .000 making string comparison with milliseconds more fragile.
//
// Also, some browsers emit `datetime-local` values without seconds when seconds are 00,
// e.g. `2024-01-01T00:00` instead of `2024-01-01T00:00:00.000`.
// In that case we must compare with minute precision (otherwise every zone looks "invalid").
const dateInTimezone = DateTime.fromISO(date, { zone }); const dateInTimezone = DateTime.fromISO(date, { zone });
const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss"); const withoutMillis = date.replace(/\.\d+/, '');
const hasSeconds = /T\d{2}:\d{2}:\d{2}$/.test(withoutMillis);
const compareFormat = hasSeconds ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm";
const exists = withoutMillis === dateInTimezone.toFormat(compareFormat);
const valid = dateInTimezone.isValid && exists; const valid = dateInTimezone.isValid && exists;
return { return {
value: zone, value: zone,