From 4a9eaed0607da6f3a6bab0b43de84662c503fdeb Mon Sep 17 00:00:00 2001 From: IacobIonut01 Date: Sat, 30 Mar 2024 13:19:00 +0200 Subject: [PATCH] Gallery: Refactor VideoDecoder to load Thumbnails for both images & videos MediaStore's Thumbnail is much faster to load than manually Signed-off-by: IacobIonut01 --- .../dot/gallery/core/coil/ThumbnailDecoder.kt | 134 +++++++++ .../gallery/core/coil/VideoFrameDecoder2.kt | 276 ------------------ .../kotlin/com/dot/gallery/core/coil/utils.kt | 49 ---- .../dot/gallery/core/coil/videoFrameIndex.kt | 134 --------- .../albums/components/AlbumComponent.kt | 37 ++- .../albums/components/PinnedAlbumsCarousel.kt | 10 +- .../common/components/MediaImage.kt | 2 + .../presentation/util/newImageLoader.kt | 23 +- 8 files changed, 181 insertions(+), 484 deletions(-) create mode 100644 app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/core/coil/VideoFrameDecoder2.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/core/coil/utils.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/core/coil/videoFrameIndex.kt diff --git a/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt new file mode 100644 index 000000000..390ed913b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt @@ -0,0 +1,134 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package com.dot.gallery.core.coil + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.Paint +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.asCoilImage +import coil3.decode.ContentMetadata +import coil3.decode.DecodeResult +import coil3.decode.DecodeUtils +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import coil3.request.bitmapConfig +import coil3.size.Size +import coil3.size.pxOrElse +import coil3.svg.internal.MIME_TYPE_SVG +import coil3.svg.isSvg +import coil3.toAndroidUri +import kotlin.math.roundToInt + +/** + * A [Decoder] that uses [ContentResolver.loadThumbnail] to fetch and decode their thumbnail from MediaStore. + */ +class ThumbnailDecoder( + private val source: ImageSource, + private val options: Options, +) : Decoder { + + @OptIn(ExperimentalCoilApi::class) + override suspend fun decode(): DecodeResult { + val metadata = source.metadata as ContentMetadata + val bitmap = options.context.contentResolver.loadThumbnail( + metadata.uri.toAndroidUri(), + options.size.toAndroidSize(), + null + ) + val normalizedBitmap = normalizeBitmap(bitmap, options.size) + + + return DecodeResult( + image = normalizedBitmap.toDrawable(options.context.resources).asCoilImage(), + isSampled = true, + ) + } + + + /** Return [inBitmap] or a copy of [inBitmap] that is valid for the input [options] and [size]. */ + private fun normalizeBitmap(inBitmap: Bitmap, size: Size): Bitmap { + // Fast path: if the input bitmap is valid, return it. + if (isConfigValid(inBitmap, options) && isSizeValid(inBitmap, options, size)) { + return inBitmap + } + + // Slow path: re-render the bitmap with the correct size + config. + val scale = DecodeUtils.computeSizeMultiplier( + srcWidth = inBitmap.width, + srcHeight = inBitmap.height, + dstWidth = size.width.pxOrElse { inBitmap.width }, + dstHeight = size.height.pxOrElse { inBitmap.height }, + scale = options.scale, + ).toFloat() + val dstWidth = (scale * inBitmap.width).roundToInt() + val dstHeight = (scale * inBitmap.height).roundToInt() + val safeConfig = when { + options.bitmapConfig == Bitmap.Config.HARDWARE -> Bitmap.Config.ARGB_8888 + else -> options.bitmapConfig + } + + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + val outBitmap = createBitmap(dstWidth, dstHeight, safeConfig) + outBitmap.applyCanvas { + scale(scale, scale) + drawBitmap(inBitmap, 0f, 0f, paint) + } + inBitmap.recycle() + + return outBitmap + } + + private fun isConfigValid(bitmap: Bitmap, options: Options): Boolean { + return bitmap.config != Bitmap.Config.HARDWARE || + options.bitmapConfig == Bitmap.Config.HARDWARE + } + + private fun isSizeValid(bitmap: Bitmap, options: Options, size: Size): Boolean { + if (options.allowInexactSize) return true + val multiplier = DecodeUtils.computeSizeMultiplier( + srcWidth = bitmap.width, + srcHeight = bitmap.height, + dstWidth = size.width.pxOrElse { bitmap.width }, + dstHeight = size.height.pxOrElse { bitmap.height }, + scale = options.scale, + ) + return multiplier == 1.0 + } + + private fun Size.toAndroidSize(fallbackWidth: Int = 200, fallbackHeight: Int = 200) = + android.util.Size( + width.pxOrElse { fallbackWidth }, + height.pxOrElse { fallbackHeight } + ) + + class Factory : Decoder.Factory { + + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder? { + if (!isApplicable(result)) return null + return ThumbnailDecoder(result.source, options) + } + + private fun isApplicable(result: SourceFetchResult): Boolean { + return with(result) { + mimeType != null && mimeType!!.isVideoOrImage && + source.metadata is ContentMetadata && !isSvg(result) + } + } + + private val String.isVideoOrImage get() = startsWith("video/") || startsWith("image/") + + private fun isSvg(result: SourceFetchResult) = result.mimeType == MIME_TYPE_SVG || DecodeUtils.isSvg(result.source.source()) + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/coil/VideoFrameDecoder2.kt b/app/src/main/kotlin/com/dot/gallery/core/coil/VideoFrameDecoder2.kt deleted file mode 100644 index 336a5f875..000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/coil/VideoFrameDecoder2.kt +++ /dev/null @@ -1,276 +0,0 @@ -@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - -package com.dot.gallery.core.coil - -import android.graphics.Bitmap -import android.graphics.Paint -import android.media.MediaMetadataRetriever -import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION -import android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT -import android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -import android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -import androidx.core.graphics.applyCanvas -import androidx.core.graphics.createBitmap -import androidx.core.graphics.drawable.toDrawable -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.asCoilImage -import coil3.decode.AssetMetadata -import coil3.decode.ContentMetadata -import coil3.decode.DecodeResult -import coil3.decode.DecodeUtils -import coil3.decode.Decoder -import coil3.decode.ImageSource -import coil3.decode.ResourceMetadata -import coil3.fetch.SourceFetchResult -import coil3.request.Options -import coil3.request.bitmapConfig -import coil3.size.Dimension.Pixels -import coil3.size.Size -import coil3.size.pxOrElse -import coil3.toAndroidUri -import coil3.util.heightPx -import coil3.util.widthPx -import coil3.video.MediaDataSourceFetcher.MediaSourceMetadata -import coil3.video.videoFrameMicros -import coil3.video.videoFrameOption -import coil3.video.videoFramePercent -import kotlin.math.roundToInt -import kotlin.math.roundToLong - -/** - * A [Decoder] that uses [MediaMetadataRetriever] to fetch and decode a frame from a video. - */ -class VideoFrameDecoder2( - private val source: ImageSource, - private val options: Options, -) : Decoder { - - @OptIn(ExperimentalCoilApi::class) - override suspend fun decode(): DecodeResult { - val result: DecodeResult - if (source.metadata is ContentMetadata) { - val bitmap = options.context.contentResolver.loadThumbnail( - (source.metadata as ContentMetadata).uri.toAndroidUri(), - android.util.Size(options.size.width.pxOrElse { 200 }, options.size.height.pxOrElse { 200 }), - null - ) - result = DecodeResult( - image = bitmap.toDrawable(options.context.resources).asCoilImage(), - isSampled = true, - ) - } else { - result = - MediaMetadataRetriever().use { retriever -> - retriever.setDataSource(source) - - // Resolve the dimensions to decode the video frame at accounting - // for the source's aspect ratio and the target's size. - var srcWidth: Int - var srcHeight: Int - val rotation = - retriever.extractMetadata(METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 - if (rotation == 90 || rotation == 270) { - srcWidth = - retriever.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 - srcHeight = - retriever.extractMetadata(METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 - } else { - srcWidth = - retriever.extractMetadata(METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 - srcHeight = - retriever.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 - } - - val dstSize = if (srcWidth > 0 && srcHeight > 0) { - val dstWidth = options.size.widthPx(options.scale) { srcWidth } - val dstHeight = options.size.heightPx(options.scale) { srcHeight } - val rawScale = DecodeUtils.computeSizeMultiplier( - srcWidth = srcWidth, - srcHeight = srcHeight, - dstWidth = dstWidth, - dstHeight = dstHeight, - scale = options.scale, - ) - val scale = if (options.allowInexactSize) { - rawScale.coerceAtMost(1.0) - } else { - rawScale - } - val width = (scale * srcWidth).roundToInt() - val height = (scale * srcHeight).roundToInt() - Size(width, height) - } else { - // We were unable to decode the video's dimensions. - // Fall back to decoding the video frame at the original size. - // We'll scale the resulting bitmap after decoding if necessary. - Size.ORIGINAL - } - - val frameMicros = computeFrameMicros(retriever) - val (dstWidth, dstHeight) = dstSize - val rawBitmap: Bitmap? = if (options.videoFrameIndex >= 0) { - retriever.getFrameAtIndex( - frameIndex = options.videoFrameIndex, - config = options.bitmapConfig, - )?.also { - srcWidth = it.width - srcHeight = it.height - } - } else if (dstWidth is Pixels && dstHeight is Pixels) { - retriever.getScaledFrameAtTime( - timeUs = frameMicros, - option = options.videoFrameOption, - dstWidth = dstWidth.px, - dstHeight = dstHeight.px, - config = options.bitmapConfig, - ) - } else { - retriever.getFrameAtTime( - timeUs = frameMicros, - option = options.videoFrameOption, - config = options.bitmapConfig, - )?.also { - srcWidth = it.width - srcHeight = it.height - } - } - - // If you encounter this exception make sure your video is encoded in a supported codec. - // https://developer.android.com/guide/topics/media/media-formats#video-formats - checkNotNull(rawBitmap) { "Failed to decode frame at $frameMicros microseconds." } - - val bitmap = normalizeBitmap(rawBitmap, dstSize) - - val isSampled = if (srcWidth > 0 && srcHeight > 0) { - DecodeUtils.computeSizeMultiplier( - srcWidth = srcWidth, - srcHeight = srcHeight, - dstWidth = bitmap.width, - dstHeight = bitmap.height, - scale = options.scale, - ) < 1.0 - } else { - // We were unable to determine the original size of the video. Assume it is sampled. - true - } - - DecodeResult( - image = bitmap.toDrawable(options.context.resources).asCoilImage(), - isSampled = isSampled, - ) - } - } - - return result - } - - private fun computeFrameMicros(retriever: MediaMetadataRetriever): Long { - val frameMicros = options.videoFrameMicros - if (frameMicros >= 0) { - return frameMicros - } - - val framePercent = options.videoFramePercent - if (framePercent >= 0) { - val durationMillis = - retriever.extractMetadata(METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L - return 1000 * (framePercent * durationMillis).roundToLong() - } - - return 0 - } - - /** Return [inBitmap] or a copy of [inBitmap] that is valid for the input [options] and [size]. */ - private fun normalizeBitmap(inBitmap: Bitmap, size: Size): Bitmap { - // Fast path: if the input bitmap is valid, return it. - if (isConfigValid(inBitmap, options) && isSizeValid(inBitmap, options, size)) { - return inBitmap - } - - // Slow path: re-render the bitmap with the correct size + config. - val scale = DecodeUtils.computeSizeMultiplier( - srcWidth = inBitmap.width, - srcHeight = inBitmap.height, - dstWidth = size.width.pxOrElse { inBitmap.width }, - dstHeight = size.height.pxOrElse { inBitmap.height }, - scale = options.scale, - ).toFloat() - val dstWidth = (scale * inBitmap.width).roundToInt() - val dstHeight = (scale * inBitmap.height).roundToInt() - val safeConfig = when { - options.bitmapConfig == Bitmap.Config.HARDWARE -> Bitmap.Config.ARGB_8888 - else -> options.bitmapConfig - } - - val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) - val outBitmap = createBitmap(dstWidth, dstHeight, safeConfig) - outBitmap.applyCanvas { - scale(scale, scale) - drawBitmap(inBitmap, 0f, 0f, paint) - } - inBitmap.recycle() - - return outBitmap - } - - private fun isConfigValid(bitmap: Bitmap, options: Options): Boolean { - return bitmap.config != Bitmap.Config.HARDWARE || - options.bitmapConfig == Bitmap.Config.HARDWARE - } - - private fun isSizeValid(bitmap: Bitmap, options: Options, size: Size): Boolean { - if (options.allowInexactSize) return true - val multiplier = DecodeUtils.computeSizeMultiplier( - srcWidth = bitmap.width, - srcHeight = bitmap.height, - dstWidth = size.width.pxOrElse { bitmap.width }, - dstHeight = size.height.pxOrElse { bitmap.height }, - scale = options.scale, - ) - return multiplier == 1.0 - } - - private fun MediaMetadataRetriever.setDataSource(source: ImageSource) { - if (source.metadata is MediaSourceMetadata) { - setDataSource((source.metadata as MediaSourceMetadata).mediaDataSource) - return - } - - when (val metadata = source.metadata) { - is AssetMetadata -> { - options.context.assets.openFd(metadata.filePath).use { - setDataSource(it.fileDescriptor, it.startOffset, it.length) - } - } - - is ContentMetadata -> { - setDataSource(options.context, metadata.uri.toAndroidUri()) - } - - is ResourceMetadata -> { - setDataSource("android.resource://${metadata.packageName}/${metadata.resId}") - } - - else -> { - setDataSource(source.file().toFile().path) - } - } - } - - class Factory : Decoder.Factory { - - override fun create( - result: SourceFetchResult, - options: Options, - imageLoader: ImageLoader, - ): Decoder? { - if (!isApplicable(result.mimeType)) return null - return VideoFrameDecoder2(result.source, options) - } - - private fun isApplicable(mimeType: String?): Boolean { - return mimeType != null && mimeType.startsWith("video/") - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/coil/utils.kt b/app/src/main/kotlin/com/dot/gallery/core/coil/utils.kt deleted file mode 100644 index 2dfdf9f03..000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/coil/utils.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.dot.gallery.core.coil - -import android.graphics.Bitmap -import android.media.MediaMetadataRetriever -import android.media.MediaMetadataRetriever.BitmapParams -import android.os.Build.VERSION.SDK_INT - -/** [MediaMetadataRetriever] doesn't implement [AutoCloseable] until API 29. */ -internal inline fun MediaMetadataRetriever.use(block: (MediaMetadataRetriever) -> T): T { - try { - return block(this) - } finally { - // We must call 'close' on API 29+ to avoid a strict mode warning. - if (SDK_INT >= 29) { - close() - } else { - release() - } - } -} - -internal fun MediaMetadataRetriever.getFrameAtTime( - timeUs: Long, - option: Int, - config: Bitmap.Config, -): Bitmap? = if (SDK_INT >= 30) { - val params = BitmapParams().apply { preferredConfig = config } - getFrameAtTime(timeUs, option, params) -} else { - getFrameAtTime(timeUs, option) -} - -internal fun MediaMetadataRetriever.getScaledFrameAtTime( - timeUs: Long, - option: Int, - dstWidth: Int, - dstHeight: Int, - config: Bitmap.Config, -): Bitmap? = if (SDK_INT >= 30) { - val params = BitmapParams().apply { preferredConfig = config } - getScaledFrameAtTime(timeUs, option, dstWidth, dstHeight, params) -} else { - getScaledFrameAtTime(timeUs, option, dstWidth, dstHeight) -} - -internal fun MediaMetadataRetriever.getFrameAtIndex( - frameIndex: Int, - config: Bitmap.Config, -): Bitmap? = getFrameAtIndex(frameIndex, BitmapParams().apply { preferredConfig = config }) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/coil/videoFrameIndex.kt b/app/src/main/kotlin/com/dot/gallery/core/coil/videoFrameIndex.kt deleted file mode 100644 index d65e55926..000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/coil/videoFrameIndex.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.dot.gallery.core.coil - -import android.media.MediaMetadataRetriever -import android.media.MediaMetadataRetriever.OPTION_CLOSEST -import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC -import android.media.MediaMetadataRetriever.OPTION_NEXT_SYNC -import android.media.MediaMetadataRetriever.OPTION_PREVIOUS_SYNC -import androidx.annotation.RequiresApi -import coil3.Extras -import coil3.getExtra -import coil3.request.ImageRequest -import coil3.request.Options - -// region videoFrameIndex - -/** - * Set the the frame index to extract from a video. - * - * When both [videoFrameIndex] and other videoFrame-prefixed properties are set, - * [videoFrameIndex] will take precedence. - */ -@RequiresApi(28) -fun ImageRequest.Builder.videoFrameIndex(frameIndex: Int) = apply { - require(frameIndex >= 0) { "frameIndex must be >= 0." } - memoryCacheKeyExtra("coil#videoFrameIndex", frameIndex.toString()) - extras[videoFrameIndexKey] = frameIndex -} - -val ImageRequest.videoFrameIndex: Int - @RequiresApi(28) - get() = getExtra(videoFrameIndexKey) - -val Options.videoFrameIndex: Int - @RequiresApi(28) - get() = getExtra(videoFrameIndexKey) - -val Extras.Key.Companion.videoFrameIndex: Extras.Key - @RequiresApi(28) - get() = videoFrameIndexKey - -private val videoFrameIndexKey = Extras.Key(default = -1) - -// endregion -// region videoFrameMicros - -/** - * Set the time **in milliseconds** of the frame to extract from a video. - * - * When both [videoFrameMicros] (or [videoFrameMillis]) and [videoFramePercent] are set, - * [videoFrameMicros] (or [videoFrameMillis]) will take precedence. - */ -fun ImageRequest.Builder.videoFrameMillis(frameMillis: Long) = - videoFrameMicros(1000 * frameMillis) - -/** - * Set the time **in microseconds** of the frame to extract from a video. - * - * When both [videoFrameMicros] (or [videoFrameMillis]) and [videoFramePercent] are set, - * [videoFrameMicros] (or [videoFrameMillis]) will take precedence. - */ -fun ImageRequest.Builder.videoFrameMicros(frameMicros: Long) = apply { - require(frameMicros >= 0) { "frameMicros must be >= 0." } - memoryCacheKeyExtra("coil#videoFrameMicros", frameMicros.toString()) - extras[videoFrameMicrosKey] = frameMicros -} - -val ImageRequest.videoFrameMicros: Long - get() = getExtra(videoFrameMicrosKey) - -val Options.videoFrameMicros: Long - get() = getExtra(videoFrameMicrosKey) - -val Extras.Key.Companion.videoFrameMicros: Extras.Key - get() = videoFrameMicrosKey - -private val videoFrameMicrosKey = Extras.Key(default = -1L) - -// endregion -// region videoFramePercent - -/** - * Set the time **as a percentage** of the total duration for the frame to extract from a video. - * - * When both [videoFrameMicros] (or [videoFrameMillis]) and [videoFramePercent] are set, - * [videoFrameMicros] (or [videoFrameMillis]) will take precedence. - */ -fun ImageRequest.Builder.videoFramePercent(framePercent: Double) = apply { - require(framePercent in 0.0..1.0) { "framePercent must be in the range [0.0, 1.0]." } - memoryCacheKeyExtra("coil#videoFramePercent", framePercent.toString()) - extras[videoFramePercentKey] = framePercent -} - -val ImageRequest.videoFramePercent: Double - get() = getExtra(videoFramePercentKey) - -val Options.videoFramePercent: Double - get() = getExtra(videoFramePercentKey) - -val Extras.Key.Companion.videoFramePercent: Extras.Key - get() = videoFramePercentKey - -private val videoFramePercentKey = Extras.Key(default = -1.0) - -// endregion -// region videoFrameOption - -/** - * Set the option for how to decode the video frame. - * - * Must be one of [OPTION_PREVIOUS_SYNC], [OPTION_NEXT_SYNC], [OPTION_CLOSEST_SYNC], [OPTION_CLOSEST]. - * - * @see MediaMetadataRetriever - */ -fun ImageRequest.Builder.videoFrameOption(option: Int) = apply { - require(option == OPTION_PREVIOUS_SYNC || - option == OPTION_NEXT_SYNC || - option == OPTION_CLOSEST_SYNC || - option == OPTION_CLOSEST) { "Invalid video frame option: $option." } - memoryCacheKeyExtra("coil#videoFrameOption", option.toString()) - extras[videoFrameOptionKey] = option -} - -val ImageRequest.videoFrameOption: Int - get() = getExtra(videoFrameOptionKey) - -val Options.videoFrameOption: Int - get() = getExtra(videoFrameOptionKey) - -val Extras.Key.Companion.videoFrameOption: Extras.Key - get() = videoFrameOptionKey - -private val videoFrameOptionKey = Extras.Key(default = OPTION_CLOSEST_SYNC) - -// endregion \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt index 9be12ab65..9e86b0bf8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt @@ -7,6 +7,7 @@ package com.dot.gallery.feature_node.presentation.albums.components import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -45,14 +46,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext +import coil3.compose.rememberAsyncImagePainter import coil3.request.CachePolicy import coil3.request.ImageRequest +import coil3.size.Scale import com.dot.gallery.R import com.dot.gallery.core.presentation.components.util.AutoResizeText import com.dot.gallery.core.presentation.components.util.FontSizeRange import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.domain.model.MediaEqualityDelegate import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionSheet import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager @@ -71,6 +74,16 @@ fun AlbumComponent( ) { val scope = rememberCoroutineScope() val appBottomSheetState = rememberAppBottomSheetState() + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(album.uri) + .memoryCachePolicy(CachePolicy.ENABLED) + .placeholderMemoryCacheKey(album.toString()) + .scale(Scale.FIT) + .build(), + modelEqualityDelegate = MediaEqualityDelegate(), + contentScale = ContentScale.FillBounds + ) Column( modifier = modifier .alpha(if (isEnabled) 1f else 0.4f) @@ -116,12 +129,12 @@ fun AlbumComponent( state = appBottomSheetState, optionList = arrayOf(optionList), headerContent = { - AsyncImage( + Image( modifier = Modifier .size(98.dp) .clip(Shapes.large), contentScale = ContentScale.Crop, - model = album.uri, + painter = painter, contentDescription = album.label ) Text( @@ -263,7 +276,17 @@ fun AlbumImage( .padding(48.dp) ) } else { - AsyncImage( + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(album.uri) + .memoryCachePolicy(CachePolicy.ENABLED) + .placeholderMemoryCacheKey(album.toString()) + .scale(Scale.FIT) + .build(), + modelEqualityDelegate = MediaEqualityDelegate(), + contentScale = ContentScale.FillBounds + ) + Image( modifier = Modifier .fillMaxSize() .border( @@ -284,11 +307,7 @@ fun AlbumImage( } } ), - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(album.uri) - .diskCachePolicy(CachePolicy.ENABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build(), + painter = painter, contentDescription = album.label, contentScale = ContentScale.Crop, ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt index 4560c5951..b7b9f0756 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt @@ -41,8 +41,10 @@ import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.size.Scale import coil3.compose.AsyncImage +import coil3.request.CachePolicy import com.dot.gallery.R import com.dot.gallery.feature_node.domain.model.Album import com.dot.gallery.feature_node.presentation.common.components.OptionItem @@ -194,7 +196,11 @@ private class PinnedAlbumsAdapter( GradientDrawable.Orientation.BOTTOM_TOP, intArrayOf(containerColor, Color.TRANSPARENT) ) - albumImage.load(album.uri) + albumImage.load(album.uri) { + scale(Scale.FIT) + memoryCachePolicy(CachePolicy.ENABLED) + placeholderMemoryCacheKey(album.toString()) + } albumImage.isClickable = true albumImage.setOnClickListener { onAlbumClick.invoke(album) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt index 4e7998e58..f5ed5d910 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt @@ -45,6 +45,7 @@ import coil3.compose.LocalPlatformContext import coil3.compose.rememberAsyncImagePainter import coil3.request.CachePolicy import coil3.request.ImageRequest +import coil3.size.Scale import com.dot.gallery.core.Constants.Animation import com.dot.gallery.core.presentation.components.CheckBox import com.dot.gallery.feature_node.domain.model.Media @@ -90,6 +91,7 @@ fun MediaImage( .data(media.uri) .memoryCachePolicy(CachePolicy.ENABLED) .placeholderMemoryCacheKey(media.toString()) + .scale(Scale.FIT) .build(), modelEqualityDelegate = MediaEqualityDelegate(), contentScale = ContentScale.FillBounds, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt index 68c47a614..487e15dda 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt @@ -2,17 +2,17 @@ package com.dot.gallery.feature_node.presentation.util import android.app.ActivityManager import androidx.core.content.getSystemService -import coil3.ComponentRegistry import coil3.ImageLoader import coil3.PlatformContext import coil3.disk.DiskCache import coil3.disk.directory import coil3.gif.AnimatedImageDecoder import coil3.memory.MemoryCache +import coil3.request.allowRgb565 import coil3.request.crossfade -import coil3.size.Precision import coil3.svg.SvgDecoder -import com.dot.gallery.core.coil.VideoFrameDecoder2 +import coil3.util.DebugLogger +import com.dot.gallery.core.coil.ThumbnailDecoder fun newImageLoader( context: PlatformContext @@ -23,9 +23,11 @@ fun newImageLoader( .components { // SVGs add(SvgDecoder.Factory(false)) - // Temporarily disabled add(JxlDecoder.Factory()) - addPlatformComponents() + // GIFs + add(AnimatedImageDecoder.Factory()) + // Thumbnails + add(ThumbnailDecoder.Factory()) } .memoryCache { MemoryCache.Builder() @@ -40,14 +42,7 @@ fun newImageLoader( } // Show a short crossfade when loading images asynchronously. .crossfade(100) - .precision(Precision.INEXACT) + .allowRgb565(true) + .logger(DebugLogger()) .build() } - - -private fun ComponentRegistry.Builder.addPlatformComponents() { - // GIFs - add(AnimatedImageDecoder.Factory()) - // Video frames - add(VideoFrameDecoder2.Factory()) -} \ No newline at end of file