dynamic thumbnail resolution

This commit is contained in:
mertalev 2025-09-09 17:51:08 -04:00
parent 7cd5b7f64d
commit e7404e7495
No known key found for this signature in database
GPG key ID: DF6ABC77AAD98C95
4 changed files with 97 additions and 9 deletions

View file

@ -3,14 +3,19 @@ package app.alextran.immich.images
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.* import android.graphics.*
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import android.provider.DocumentsContract
import android.provider.MediaStore.Images import android.provider.MediaStore.Images
import android.provider.MediaStore.Video import android.provider.MediaStore.Video
import android.system.Int64Ref
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.* import kotlin.math.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -172,7 +177,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
} }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS) Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
@ -185,7 +190,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled() signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id) val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS) Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
@ -215,4 +220,72 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
ref.get() 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
}
} }

View file

@ -16,8 +16,9 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; 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 @override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) { Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -37,7 +38,12 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
} }
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _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); return loadRequest(request, decode);
} }
@ -45,7 +51,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id; return id == other.id && (!exact || size == other.size);
} }
return false; return false;
} }
@ -60,7 +66,12 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
LocalFullImageProvider({required this.id, required this.assetType, required this.size}); LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
LocalThumbProvider? initialProvider,
});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -71,7 +82,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), initialImage: getInitialImage(LocalThumbProvider(id: id, assetType: assetType, exact: false)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),

View file

@ -2,7 +2,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256); 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 double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;

View file

@ -121,6 +121,7 @@ class _FixedSegmentRow extends ConsumerWidget {
} }
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) { Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
final size = Size.square(tileHeight);
return FixedTimelineRow( return FixedTimelineRow(
dimension: tileHeight, dimension: tileHeight,
spacing: spacing, spacing: spacing,
@ -134,6 +135,7 @@ class _FixedSegmentRow extends ConsumerWidget {
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i], asset: assets[i],
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
size: size,
), ),
), ),
], ],
@ -144,8 +146,9 @@ class _FixedSegmentRow extends ConsumerWidget {
class _AssetTileWidget extends ConsumerWidget { class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset; final BaseAsset asset;
final int assetIndex; 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 { Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider); final multiSelectState = ref.read(multiSelectProvider);
@ -203,6 +206,7 @@ class _AssetTileWidget extends ConsumerWidget {
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
size: size,
), ),
), ),
); );