Compare commits

...

6 Commits

Author SHA1 Message Date
Santo Shakil e5c3395b3a fix(mobile): fix squished long image previews on ios 2026-06-29 18:45:04 +06:00
Santo Shakil 36ff8f5c14 refactor(mobile): reuse the shared request options for the original 2026-06-29 13:44:02 +06:00
Santo Shakil 0701948780 fix(mobile): bound the original to 16384 with fast resize 2026-06-29 13:01:53 +06:00
Santo Shakil 46af2a709f fix(mobile): stop long images squishing on ios 2026-06-28 16:42:48 +06:00
github-actions ac74bca18b chore: version v3.0.0-rc.4 2026-06-27 13:14:47 +00:00
shenlong e9d1951858 fix: dispatch menu onPressed manually (#29353)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-27 08:10:47 -05:00
19 changed files with 178 additions and 27 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v3.0.0-rc.3",
"url": "https://docs.v3.0.0-rc.3.archive.immich.app"
"label": "v3.0.0-rc.4",
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
},
{
"label": "v2.7.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.0rc3"
version = "3.0.0rc4"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.0rc3"
version = "3.0.0rc4"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+4 -4
View File
@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3051,
"android.injected.version.name" => "3.0.0-rc.3",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
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', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3051,
"android.injected.version.name" => "3.0.0-rc.3",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
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')
+10 -2
View File
@@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let maxOriginalPixelSize: CGFloat = 16384
private static let registry = RequestRegistry<ImageRequest>()
@@ -108,11 +109,18 @@ class LocalImageApiImpl: LocalImageApi {
]))
}
// PHImageManagerMaximumSize returns a distorted aspect ratio for images too large to render
// (extreme panoramas / long screenshots). Bound the long edge to 16384 (the GPU max texture
// size) with aspectFit so the true ratio is kept and the buffer stays renderable. .fast resize
// is enough, we only need the long edge under the limit.
let isOriginal = !(width > 0 && height > 0)
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
targetSize: isOriginal
? CGSize(width: Self.maxOriginalPixelSize, height: Self.maxOriginalPixelSize)
: CGSize(width: Double(width), height: Double(height)),
contentMode: isOriginal ? .aspectFit : .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
@@ -49,14 +49,21 @@ class BaseActionButton extends ConsumerWidget {
if (menuItem) {
final iconColor = this.iconColor;
final onPressed = this.onPressed;
return MenuItemButton(
closeOnActivate: false,
style: MenuItemButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
leadingIcon: Icon(iconData, color: iconColor, size: 20),
onPressed: onPressed,
onPressed: onPressed == null
? null
: () {
onPressed();
MenuController.maybeOf(context)?.close();
},
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
);
}
@@ -159,7 +159,14 @@ ImageProvider getFullImageProvider(
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
provider = LocalFullImageProvider(
id: id,
size: size,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
width: asset.width,
height: asset.height,
);
} else {
final String assetId;
final String thumbhash;
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -8,6 +10,9 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
// iphone's max gpu texture size. images longer than this squish in the preview.
const _kMaxTextureSize = 16384;
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
final String id;
@@ -59,8 +64,36 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size;
final AssetType assetType;
final bool isAnimated;
final int? width;
final int? height;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
required this.isAnimated,
this.width,
this.height,
});
Size _previewTarget(double dpr, bool previewIsFinal) =>
previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal);
// long images squish on aspectFill; use an aspect-correct target (full detail if final, else light).
@visibleForTesting
static Size previewTargetSize(double boxW, double boxH, int? width, int? height, {required bool previewIsFinal}) {
if (width == null || height == null || width <= 0 || height <= 0) {
return Size(boxW, boxH);
}
final imgLong = math.max(width, height).toDouble();
final coverLong = imgLong * math.max(boxW / width, boxH / height);
if (coverLong <= _kMaxTextureSize) {
return Size(boxW, boxH);
}
final bound = previewIsFinal ? _kMaxTextureSize.toDouble() : math.max(boxW, boxH);
final scale = math.min(1.0, bound / imgLong);
return Size(width * scale, height * scale);
}
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -108,7 +141,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, !loadOriginal),
assetType: key.assetType,
);
yield* loadRequest(request, decode, isFinal: !loadOriginal);
@@ -136,7 +169,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, false),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode, isFinal: false);
@@ -163,11 +196,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return true;
}
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated;
return id == other.id &&
size == other.size &&
isAnimated == other.isAnimated &&
width == other.width &&
height == other.height;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ width.hashCode ^ height.hashCode;
}
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.0-rc.3
- API version: 3.0.0-rc.4
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.0-rc.3+3051
version: 3.0.0-rc.4+3052
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -0,0 +1,92 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
void main() {
group('LocalFullImageProvider.previewTargetSize', () {
// a typical phone preview box (logical size * devicePixelRatio).
const boxW = 1179.0;
const boxH = 2556.0;
const oldBox = Size(boxW, boxH);
test('normal landscape keeps the exact old box (no preview regression)', () {
// 4:3, cover long edge ~3407 < 16384 -> not gated, regardless of previewIsFinal.
expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 4032, 3024, previewIsFinal: true), oldBox);
expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 4032, 3024, previewIsFinal: false), oldBox);
});
test('normal portrait keeps the exact old box', () {
expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 3024, 4032, previewIsFinal: true), oldBox);
});
test('null or zero dims fall back to the old box', () {
expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, null, null, previewIsFinal: true), oldBox);
expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 0, 0, previewIsFinal: true), oldBox);
expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 1000, 0, previewIsFinal: true), oldBox);
});
test('extreme panorama, FINAL preview (load-original off): capped at the texture limit, ratio kept', () {
final t = LocalFullImageProvider.previewTargetSize(boxW, boxH, 1000, 30000, previewIsFinal: true);
expect(t, isNot(oldBox));
expect(t.longestSide, closeTo(16384, 1));
expect(t.width / t.height, closeTo(1000 / 30000, 1e-6));
});
test('extreme panorama, NON-final preview (load-original on): light, capped at screen long edge, ratio kept', () {
final t = LocalFullImageProvider.previewTargetSize(boxW, boxH, 1000, 30000, previewIsFinal: false);
expect(t, isNot(oldBox));
expect(t.longestSide, closeTo(boxH, 1)); // screen long edge = max(boxW, boxH), the original follows at 16384
expect(t.width / t.height, closeTo(1000 / 30000, 1e-6));
});
test('extreme but small source is never upscaled', () {
// ratio 40 -> gated, but the source long edge (2000) < texture limit so scale clamps to 1.0.
expect(
LocalFullImageProvider.previewTargetSize(boxW, boxH, 50, 2000, previewIsFinal: true),
const Size(50, 2000),
);
});
test('narrow image is gated in but not upscaled (long edge already under the texture limit)', () {
// ratio 100: cover overshoots 16384 so it's gated, but the source long edge (10000) < 16384 -> scale clamps to 1.0.
expect(
LocalFullImageProvider.previewTargetSize(boxW, boxH, 100, 10000, previewIsFinal: true),
const Size(100, 10000),
);
});
test('gate boundary: at the texture limit keeps the old box, just over switches to the fix', () {
// square source -> coverLong == the box long edge.
expect(
LocalFullImageProvider.previewTargetSize(16384, 100, 1000, 1000, previewIsFinal: true),
const Size(16384, 100),
);
final over = LocalFullImageProvider.previewTargetSize(16385, 100, 1000, 1000, previewIsFinal: true);
expect(over, isNot(const Size(16385, 100)));
expect(over, const Size(1000, 1000));
});
});
group('LocalFullImageProvider equality', () {
LocalFullImageProvider make({int? width, int? height}) => LocalFullImageProvider(
id: 'a',
assetType: AssetType.image,
size: const Size(100, 200),
isAnimated: false,
width: width,
height: height,
);
test('same id/size/isAnimated but different w/h are not equal (distinct cache entries)', () {
expect(make(width: 1000, height: 30000) == make(width: 4032, height: 3024), isFalse);
});
test('identical config is equal and shares a hashCode', () {
final a = make(width: 100, height: 200);
final b = make(width: 100, height: 200);
expect(a, b);
expect(a.hashCode, b.hashCode);
});
});
}
+1 -1
View File
@@ -16206,7 +16206,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"contact": {}
},
"tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 3.0.0-rc.3
* 3.0.0-rc.4
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {