From 47b791b95fa367ef6083ef511f7702728343f649 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 28 May 2023 18:59:56 -0600 Subject: [PATCH] image: allow non-square album covers Make the app UI properly handle album covers that are not 1:1, instead of just cropping them. This required switching to Coil's rounded corners transformation outright so that the non-1:1 image can sit inside the CoverView in a way that actually looks good I'm pretty confident this will work, but there might be some edge cases since coil's transformation is really finicky. Resolves #355. --- .../java/org/oxycblt/auxio/MainFragment.kt | 2 +- .../org/oxycblt/auxio/image/BitmapProvider.kt | 4 - .../java/org/oxycblt/auxio/image/CoverView.kt | 11 +- .../image/RoundedCornersTransformation.kt | 139 ++++++++++++++++++ .../auxio/image/extractor/CoverExtractor.kt | 25 +++- .../image/extractor/SquareFrameTransform.kt | 57 ------- .../playback/persist/PersistenceDatabase.kt | 1 + .../oxycblt/auxio/widgets/WidgetComponent.kt | 7 +- app/src/main/res/layout/item_disc_header.xml | 2 +- 9 files changed, 169 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index cfa4bd7b8..9dee9473b 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -514,7 +514,7 @@ class MainFragment : val exploreNavController = binding.exploreNavHost.findNavController() // TODO: Debug why this fails sometimes on the playback sheet - // TODO: Add playlist editing + // TODO: Add playlist editing to enabled check // TODO: Can this be split up? isEnabled = diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index 76a55dea1..ad81c25a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -27,7 +27,6 @@ import coil.request.ImageRequest import coil.size.Size import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.Song /** @@ -98,9 +97,6 @@ constructor( .data(listOf(song)) // Use ORIGINAL sizing, as we are not loading into any View-like component. .size(Size.ORIGINAL)) - // Override the target in order to deliver the bitmap to the given - // listener. - .transformations(SquareFrameTransform.INSTANCE) .target( onSuccess = { synchronized(this) { diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index b783f849c..86707f22e 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -42,7 +42,6 @@ import com.google.android.material.shape.MaterialShapeDrawable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -63,8 +62,6 @@ import org.oxycblt.auxio.util.getInteger * itself can be overridden if populated like a normal [FrameLayout]. * * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Enable non-square covers as soon as I can confirm that my workaround is okay */ @AndroidEntryPoint class CoverView @@ -142,7 +139,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr child.apply { // If there are rounded corners, we want to make sure view content will be cropped // with it. - clipToOutline = true + clipToOutline = this != image background = MaterialShapeDrawable().apply { fillColor = context.getColorCompat(R.color.sel_cover_bg) @@ -316,7 +313,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr ImageRequest.Builder(context) .data(songs) .error(StyledDrawable(context, context.getDrawableCompat(errorRes))) - .transformations(SquareFrameTransform.INSTANCE) + .transformations(RoundedCornersTransformation(cornerRadius)) .target(image) .build() // Dispose of any previous image request and load a new image. @@ -335,8 +332,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr override fun draw(canvas: Canvas) { // Resize the drawable such that it's always 1/4 the size of the image and // centered in the middle of the canvas. - val adjustWidth = bounds.width() / 4 - val adjustHeight = bounds.height() / 4 + val adjustWidth = inner.bounds.width() / 4 + val adjustHeight = inner.bounds.height() / 4 inner.bounds.set( adjustWidth, adjustHeight, diff --git a/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt new file mode 100644 index 000000000..c25770ec4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2023 Auxio Project + * RoundedCornersTransformation.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image + +import android.graphics.Bitmap +import android.graphics.Bitmap.createBitmap +import android.graphics.BitmapShader +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.RectF +import android.graphics.Shader +import androidx.annotation.Px +import androidx.core.graphics.applyCanvas +import coil.decode.DecodeUtils +import coil.size.Scale +import coil.size.Size +import coil.size.pxOrElse +import coil.transform.Transformation +import kotlin.math.roundToInt + +/** + * A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio + * images without cropping them. + * + * @author Coil Team, Alexander Capehart (OxygenCobalt) + */ +class RoundedCornersTransformation( + @Px private val topLeft: Float = 0f, + @Px private val topRight: Float = 0f, + @Px private val bottomLeft: Float = 0f, + @Px private val bottomRight: Float = 0f +) : Transformation { + + constructor(@Px radius: Float) : this(radius, radius, radius, radius) + + init { + require(topLeft >= 0 && topRight >= 0 && bottomLeft >= 0 && bottomRight >= 0) { + "All radii must be >= 0." + } + } + + override val cacheKey = "${javaClass.name}-$topLeft,$topRight,$bottomLeft,$bottomRight" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + + val (outputWidth, outputHeight) = calculateOutputSize(input, size) + + val output = createBitmap(outputWidth, outputHeight, input.config) + output.applyCanvas { + drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + val matrix = Matrix() + val multiplier = + DecodeUtils.computeSizeMultiplier( + srcWidth = input.width, + srcHeight = input.height, + dstWidth = outputWidth, + dstHeight = outputHeight, + scale = Scale.FILL) + .toFloat() + val dx = (outputWidth - multiplier * input.width) / 2 + val dy = (outputHeight - multiplier * input.height) / 2 + matrix.setTranslate(dx, dy) + matrix.preScale(multiplier, multiplier) + + val shader = BitmapShader(input, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + shader.setLocalMatrix(matrix) + paint.shader = shader + + val radii = + floatArrayOf( + topLeft, + topLeft, + topRight, + topRight, + bottomRight, + bottomRight, + bottomLeft, + bottomLeft, + ) + val rect = RectF(0f, 0f, width.toFloat(), height.toFloat()) + val path = Path().apply { addRoundRect(rect, radii, Path.Direction.CW) } + drawPath(path, paint) + } + + return output + } + + private fun calculateOutputSize(input: Bitmap, size: Size): Pair { + // MODIFICATION: Remove short-circuiting for original size and input size + val multiplier = + DecodeUtils.computeSizeMultiplier( + srcWidth = input.width, + srcHeight = input.height, + dstWidth = size.width.pxOrElse { Int.MIN_VALUE }, + dstHeight = size.height.pxOrElse { Int.MIN_VALUE }, + scale = Scale.FIT) + val outputWidth = (multiplier * input.width).roundToInt() + val outputHeight = (multiplier * input.height).roundToInt() + return outputWidth to outputHeight + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is RoundedCornersTransformation && + topLeft == other.topLeft && + topRight == other.topRight && + bottomLeft == other.bottomLeft && + bottomRight == other.bottomRight + } + + override fun hashCode(): Int { + var result = topLeft.hashCode() + result = 31 * result + topRight.hashCode() + result = 31 * result + bottomLeft.hashCode() + result = 31 * result + bottomRight.hashCode() + return result + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index c835f5c63..ef9af4c3f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -43,6 +43,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.io.ByteArrayInputStream import java.io.InputStream import javax.inject.Inject +import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.asDeferred import kotlinx.coroutines.withContext @@ -224,10 +225,9 @@ constructor( break } - // Run the bitmap through a transform to reflect the configuration of other images. - val bitmap = - SquareFrameTransform.INSTANCE.transform( - BitmapFactory.decodeStream(stream), mosaicFrameSize) + // Crop the bitmap down to a square so it leaves no empty space + // TODO: Work around this + val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize) canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) x += bitmap.width @@ -252,4 +252,21 @@ constructor( val size = pxOrElse { 512 } return if (size.mod(2) > 0) size + 1 else size } + + private fun cropBitmap(input: Bitmap, size: Size): Bitmap { + // Find the smaller dimension and then take a center portion of the image that + // has that size. + val dstSize = min(input.width, input.height) + val x = (input.width - dstSize) / 2 + val y = (input.height - dstSize) / 2 + val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) + + val desiredWidth = size.width.pxOrElse { dstSize } + val desiredHeight = size.height.pxOrElse { dstSize } + if (dstSize != desiredWidth || dstSize != desiredHeight) { + // Image is not the desired size, upscale it. + return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) + } + return dst + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt deleted file mode 100644 index b8d9de4e8..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * SquareFrameTransform.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.image.extractor - -import android.graphics.Bitmap -import coil.size.Size -import coil.size.pxOrElse -import coil.transform.Transformation -import kotlin.math.min - -/** - * A transformation that performs a center crop-style transformation on an image. Allowing this - * behavior to be intrinsic without any view configuration. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class SquareFrameTransform : Transformation { - override val cacheKey = "SquareFrameTransform" - - override suspend fun transform(input: Bitmap, size: Size): Bitmap { - // Find the smaller dimension and then take a center portion of the image that - // has that size. - val dstSize = min(input.width, input.height) - val x = (input.width - dstSize) / 2 - val y = (input.height - dstSize) / 2 - val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) - - val desiredWidth = size.width.pxOrElse { dstSize } - val desiredHeight = size.height.pxOrElse { dstSize } - if (dstSize != desiredWidth || dstSize != desiredHeight) { - // Image is not the desired size, upscale it. - return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) - } - return dst - } - - companion object { - /** A re-usable instance. */ - val INSTANCE = SquareFrameTransform() - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index 545038207..93e387068 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -127,6 +127,7 @@ interface QueueDao { } // TODO: Figure out how to get RepeatMode to map to an int instead of a string +// TODO: Use intrinsic table names rather than custom names @Entity(tableName = PlaybackState.TABLE_NAME) data class PlaybackState( @PrimaryKey val id: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 8aba7f42c..1cb3f599c 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -22,13 +22,12 @@ import android.content.Context import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest -import coil.transform.RoundedCornersTransformation import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.image.extractor.SquareFrameTransform +import org.oxycblt.auxio.image.RoundedCornersTransformation import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.queue.Queue @@ -101,9 +100,7 @@ constructor( // rounded corners. builder .size(getSafeRemoteViewsImageSize(context, 10f)) - .transformations( - SquareFrameTransform.INSTANCE, - RoundedCornersTransformation(cornerRadius.toFloat())) + .transformations(RoundedCornersTransformation(cornerRadius.toFloat())) } else { builder.size(getSafeRemoteViewsImageSize(context)) } diff --git a/app/src/main/res/layout/item_disc_header.xml b/app/src/main/res/layout/item_disc_header.xml index 96f7ff7cd..6ddc08409 100644 --- a/app/src/main/res/layout/item_disc_header.xml +++ b/app/src/main/res/layout/item_disc_header.xml @@ -25,7 +25,7 @@ android:id="@+id/disc_icon" android:layout_width="match_parent" android:layout_height="match_parent" - android:src="@drawable/ic_add_24" + android:src="@drawable/ic_album_24" android:scaleType="center" app:tint="@color/sel_on_cover_bg" tools:ignore="ContentDescription" />