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.
This commit is contained in:
Alexander Capehart 2023-05-28 18:59:56 -06:00
parent 770ae77eed
commit 47b791b95f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 169 additions and 79 deletions

View file

@ -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 =

View file

@ -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) {

View file

@ -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,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, Int> {
// 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
}
}

View file

@ -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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -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,

View file

@ -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))
}

View file

@ -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" />