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:
parent
770ae77eed
commit
47b791b95f
9 changed files with 169 additions and 79 deletions
|
@ -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 =
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
Loading…
Reference in a new issue