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()
|
val exploreNavController = binding.exploreNavHost.findNavController()
|
||||||
|
|
||||||
// TODO: Debug why this fails sometimes on the playback sheet
|
// 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?
|
// TODO: Can this be split up?
|
||||||
|
|
||||||
isEnabled =
|
isEnabled =
|
||||||
|
|
|
@ -27,7 +27,6 @@ import coil.request.ImageRequest
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -98,9 +97,6 @@ constructor(
|
||||||
.data(listOf(song))
|
.data(listOf(song))
|
||||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||||
.size(Size.ORIGINAL))
|
.size(Size.ORIGINAL))
|
||||||
// Override the target in order to deliver the bitmap to the given
|
|
||||||
// listener.
|
|
||||||
.transformations(SquareFrameTransform.INSTANCE)
|
|
||||||
.target(
|
.target(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
|
|
|
@ -42,7 +42,6 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
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].
|
* itself can be overridden if populated like a normal [FrameLayout].
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
|
||||||
* TODO: Enable non-square covers as soon as I can confirm that my workaround is okay
|
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class CoverView
|
class CoverView
|
||||||
|
@ -142,7 +139,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
child.apply {
|
child.apply {
|
||||||
// If there are rounded corners, we want to make sure view content will be cropped
|
// If there are rounded corners, we want to make sure view content will be cropped
|
||||||
// with it.
|
// with it.
|
||||||
clipToOutline = true
|
clipToOutline = this != image
|
||||||
background =
|
background =
|
||||||
MaterialShapeDrawable().apply {
|
MaterialShapeDrawable().apply {
|
||||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||||
|
@ -316,7 +313,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(songs)
|
.data(songs)
|
||||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
||||||
.transformations(SquareFrameTransform.INSTANCE)
|
.transformations(RoundedCornersTransformation(cornerRadius))
|
||||||
.target(image)
|
.target(image)
|
||||||
.build()
|
.build()
|
||||||
// Dispose of any previous image request and load a new image.
|
// 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) {
|
override fun draw(canvas: Canvas) {
|
||||||
// Resize the drawable such that it's always 1/4 the size of the image and
|
// Resize the drawable such that it's always 1/4 the size of the image and
|
||||||
// centered in the middle of the canvas.
|
// centered in the middle of the canvas.
|
||||||
val adjustWidth = bounds.width() / 4
|
val adjustWidth = inner.bounds.width() / 4
|
||||||
val adjustHeight = bounds.height() / 4
|
val adjustHeight = inner.bounds.height() / 4
|
||||||
inner.bounds.set(
|
inner.bounds.set(
|
||||||
adjustWidth,
|
adjustWidth,
|
||||||
adjustHeight,
|
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.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.min
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.guava.asDeferred
|
import kotlinx.coroutines.guava.asDeferred
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -224,10 +225,9 @@ constructor(
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the bitmap through a transform to reflect the configuration of other images.
|
// Crop the bitmap down to a square so it leaves no empty space
|
||||||
val bitmap =
|
// TODO: Work around this
|
||||||
SquareFrameTransform.INSTANCE.transform(
|
val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
|
||||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||||
|
|
||||||
x += bitmap.width
|
x += bitmap.width
|
||||||
|
@ -252,4 +252,21 @@ constructor(
|
||||||
val size = pxOrElse { 512 }
|
val size = pxOrElse { 512 }
|
||||||
return if (size.mod(2) > 0) size + 1 else size
|
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: 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)
|
@Entity(tableName = PlaybackState.TABLE_NAME)
|
||||||
data class PlaybackState(
|
data class PlaybackState(
|
||||||
@PrimaryKey val id: Int,
|
@PrimaryKey val id: Int,
|
||||||
|
|
|
@ -22,13 +22,12 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.transform.RoundedCornersTransformation
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.BitmapProvider
|
import org.oxycblt.auxio.image.BitmapProvider
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
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.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.queue.Queue
|
import org.oxycblt.auxio.playback.queue.Queue
|
||||||
|
@ -101,9 +100,7 @@ constructor(
|
||||||
// rounded corners.
|
// rounded corners.
|
||||||
builder
|
builder
|
||||||
.size(getSafeRemoteViewsImageSize(context, 10f))
|
.size(getSafeRemoteViewsImageSize(context, 10f))
|
||||||
.transformations(
|
.transformations(RoundedCornersTransformation(cornerRadius.toFloat()))
|
||||||
SquareFrameTransform.INSTANCE,
|
|
||||||
RoundedCornersTransformation(cornerRadius.toFloat()))
|
|
||||||
} else {
|
} else {
|
||||||
builder.size(getSafeRemoteViewsImageSize(context))
|
builder.size(getSafeRemoteViewsImageSize(context))
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
android:id="@+id/disc_icon"
|
android:id="@+id/disc_icon"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:src="@drawable/ic_add_24"
|
android:src="@drawable/ic_album_24"
|
||||||
android:scaleType="center"
|
android:scaleType="center"
|
||||||
app:tint="@color/sel_on_cover_bg"
|
app:tint="@color/sel_on_cover_bg"
|
||||||
tools:ignore="ContentDescription" />
|
tools:ignore="ContentDescription" />
|
||||||
|
|
Loading…
Reference in a new issue