diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt index 1ccd742d67..a9804834c6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt @@ -3,14 +3,19 @@ package app.alextran.immich.images import android.content.ContentResolver import android.content.ContentUris import android.content.Context +import android.content.res.Resources import android.graphics.* import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.CancellationSignal import android.os.OperationCanceledException +import android.provider.DocumentsContract import android.provider.MediaStore.Images import android.provider.MediaStore.Video +import android.system.Int64Ref import android.util.Size +import androidx.annotation.RequiresApi import java.nio.ByteBuffer import kotlin.math.* import java.util.concurrent.Executors @@ -172,7 +177,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) + loadThumbnail(uri, Size(targetWidth, targetHeight), signal) } else { signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) } Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS) @@ -185,7 +190,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { signal.throwIfCanceled() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id) - resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) + loadThumbnail(uri, Size(targetWidth, targetHeight), signal) } else { signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) } Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS) @@ -215,4 +220,72 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { ref.get() } } + + /* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + @RequiresApi(Build.VERSION_CODES.Q) + fun loadThumbnail(uri: Uri, size: Size, signal: CancellationSignal?): Bitmap { + // Convert to Point, since that's what the API is defined as + val opts = Bundle() + if (size.width < 512 && size.height < 512) { + opts.putParcelable(ContentResolver.EXTRA_SIZE, Point(size.width, size.height)) + } + val orientation = Int64Ref(0) + + var bitmap = + ImageDecoder.decodeBitmap( + ImageDecoder.createSource { + val afd = + resolver.openTypedAssetFile(uri, "image/*", opts, signal) + ?: throw Resources.NotFoundException("Asset $uri not found") + val extras = afd.extras + orientation.value = + (extras?.getInt(DocumentsContract.EXTRA_ORIENTATION, 0) ?: 0).toLong() + afd + } + ) { decoder: ImageDecoder, info: ImageDecoder.ImageInfo, _: ImageDecoder.Source -> + decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE) + // One last-ditch check to see if we've been canceled. + signal?.throwIfCanceled() + + // We requested a rough thumbnail size, but the remote size may have + // returned something giant, so defensively scale down as needed. + // This is modified from the original to target the smaller edge instead of the larger edge. + val widthSample = info.size.width.toDouble() / size.width + val heightSample = info.size.height.toDouble() / size.height + val sample = min(widthSample, heightSample) + if (sample > 1) { + val width = (info.size.width / sample).toInt() + val height = (info.size.height / sample).toInt() + decoder.setTargetSize(width, height) + } + } + + // Transform the bitmap if requested. We use a side-channel to + // communicate the orientation, since EXIF thumbnails don't contain + // the rotation flags of the original image. + if (orientation.value != 0L) { + val width = bitmap.getWidth() + val height = bitmap.getHeight() + + val m = Matrix() + m.setRotate(orientation.value.toFloat(), (width / 2).toFloat(), (height / 2).toFloat()) + bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false) + } + + return bitmap + } } diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index f90961ea5a..a804829383 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -16,8 +16,9 @@ class LocalThumbProvider extends CancellableImageProvider final String id; final Size size; final AssetType assetType; + final bool exact; - LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution}); + LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution, this.exact = true}); @override Future obtainKey(ImageConfiguration configuration) { @@ -37,7 +38,12 @@ class LocalThumbProvider extends CancellableImageProvider } Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) { - final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType); + final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; + final request = this.request = LocalImageRequest( + localId: key.id, + size: key.size * devicePixelRatio, + assetType: key.assetType, + ); return loadRequest(request, decode); } @@ -45,7 +51,7 @@ class LocalThumbProvider extends CancellableImageProvider bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalThumbProvider) { - return id == other.id; + return id == other.id && (!exact || size == other.size); } return false; } @@ -60,7 +66,12 @@ class LocalFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -71,7 +82,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index cfe96b1c81..c659e4cb86 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -2,7 +2,7 @@ import 'dart:ui'; const double kTimelineHeaderExtent = 80.0; const Size kTimelineFixedTileExtent = Size.square(256); -const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size +const Size kThumbnailResolution = Size.square(128); const double kTimelineSpacing = 2.0; const int kTimelineColumnCount = 3; diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 8121bb76d3..3a0461b3c4 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -121,6 +121,7 @@ class _FixedSegmentRow extends ConsumerWidget { } Widget _buildAssetRow(BuildContext context, List assets, TimelineService timelineService) { + final size = Size.square(tileHeight); return FixedTimelineRow( dimension: tileHeight, spacing: spacing, @@ -134,6 +135,7 @@ class _FixedSegmentRow extends ConsumerWidget { key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), asset: assets[i], assetIndex: assetIndex + i, + size: size, ), ), ], @@ -144,8 +146,9 @@ class _FixedSegmentRow extends ConsumerWidget { class _AssetTileWidget extends ConsumerWidget { final BaseAsset asset; final int assetIndex; + final Size size; - const _AssetTileWidget({super.key, required this.asset, required this.assetIndex}); + const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size}); Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async { final multiSelectState = ref.read(multiSelectProvider); @@ -203,6 +206,7 @@ class _AssetTileWidget extends ConsumerWidget { lockSelection: lockSelection, showStorageIndicator: showStorageIndicator, heroOffset: heroOffset, + size: size, ), ), );