mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
Compare commits
10 Commits
feat/dynam
...
v2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d7956c07 | ||
|
|
22d3fd3b92 | ||
|
|
a469e86b32 | ||
|
|
138c9232df | ||
|
|
2e1f8625ec | ||
|
|
f7cbb7417c | ||
|
|
125de91c71 | ||
|
|
c9b58f5893 | ||
|
|
640fd7308b | ||
|
|
557a79f747 |
@@ -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",
|
||||||
|
|||||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "2.4.0",
|
"version": "2.4.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user