coil: force all images to a 1:1 aspect ratio

Crop all images to a 1:1 aspect ratio.

Sometimes cover art will not be a square. I never realized this because
all covers in my music library are square. This led to many strange bugs
that I would rather avoid, so create a new transformation that crops the
image to a 1:1 aspect ratio.

The reason why we use a transformation is that it won't be dependent on
the actual state of the ImageView, which is what would happen if we
used ScaleType.CENTER_CROP. In the case that we use a very weird hacky
ImageView like in the widgets, doing such would result in any cropping
not actually working correctly. It's better to do it in the
Transformation stage simply to ensure consistency.
This commit is contained in:
OxygenCobalt 2022-02-21 19:34:25 -07:00
parent 6fc3c9374c
commit e1dbe6c40c
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 89 additions and 54 deletions

View file

@ -5,6 +5,9 @@
#### What's Improved
- Shuffle and Repeat mode buttons now have more contrast when they are turned on
#### What's Changed
- All cover art is now cropped to a 1:1 aspect ratio
#### Dev/Meta
- Enabled elevation drop shadows below Android P for consistency
- Reworked dynamic color usage

View file

@ -68,11 +68,11 @@ fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) {
// We don't round album covers by default as it desecrates album artwork, but we do provide
// an option if one wants it.
// As for why we use clipToOutline instead of coil's RoundedCornersTransformation, the transform
// uses the dimensions of the image to create the corners, which results in inconsistent corners
// across loaded cover art.
// As for why we use clipToOutline instead of coils RoundedCornersTransformation, the radii
// of an image's corners is dependent on the actual dimensions of the image, which would force
// us to resize all images to a fixed size. clipToOutline is pretty much always cheaper as long
// as we have a perfectly-square image.
val settingsManager = SettingsManager.getInstance()
if (settingsManager.roundCovers && background == null) {
setBackgroundResource(R.drawable.ui_rounded_cutout)
clipToOutline = true
@ -83,6 +83,7 @@ fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) {
load(music) {
error(error)
transformations(SquareFrameTransform())
}
}
@ -102,6 +103,7 @@ fun loadBitmap(
ImageRequest.Builder(context)
.data(song.album)
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform())
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }

View file

@ -0,0 +1,40 @@
package org.oxycblt.auxio.coil
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, however unlike
* the actual ScaleType, this isn't affected by any hacks we do with ImageView itself.
* @author OxygenCobalt
*/
class SquareFrameTransform : Transformation {
override val cacheKey: String
get() = "SquareFrameTransform"
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
val dstSize = min(input.width, input.height)
val x = (input.width - dstSize) / 2
val y = (input.height - dstSize) / 2
val wantedWidth = size.width.pxOrElse { dstSize }
val wantedHeight = size.height.pxOrElse { dstSize }
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
if (dstSize != wantedWidth || dstSize != wantedHeight) {
// Desired size differs from the cropped size, resize the bitmap.
return Bitmap.createScaledBitmap(
dst,
wantedWidth,
wantedHeight,
true
)
}
return dst
}
}

View file

@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageButton
import org.oxycblt.auxio.R
@ -32,7 +31,13 @@ class PlaybackButton @JvmOverloads constructor(
private val centerMatrix = Matrix()
private val matrixSrc = RectF()
private val matrixDst = RectF()
private val indicatorDrawable: Drawable?
private val indicatorDrawable = context.getDrawableSafe(R.drawable.ui_indicator)
var hasIndicator = false
set(value) {
field = value
invalidate()
}
init {
val size = context.getDimenSizeSafe(R.dimen.size_btn_small)
@ -42,14 +47,7 @@ class PlaybackButton @JvmOverloads constructor(
setBackgroundResource(R.drawable.ui_large_unbounded_ripple)
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.PlaybackButton)
val hasIndicator = styledAttrs.getBoolean(R.styleable.PlaybackButton_hasIndicator, false)
indicatorDrawable = if (hasIndicator) {
context.getDrawableSafe(R.drawable.ui_indicator)
} else {
null
}
hasIndicator = styledAttrs.getBoolean(R.styleable.PlaybackButton_hasIndicator, false)
styledAttrs.recycle()
}
@ -74,20 +72,18 @@ class PlaybackButton @JvmOverloads constructor(
}
}
indicatorDrawable?.let { indicator ->
val x = (measuredWidth - indicator.intrinsicWidth) / 2
val y = ((measuredHeight - iconSize) / 2) + iconSize
val x = (measuredWidth - indicatorDrawable.intrinsicWidth) / 2
val y = ((measuredHeight - iconSize) / 2) + iconSize
indicator.bounds.set(
x, y, x + indicator.intrinsicWidth, y + indicator.intrinsicHeight
)
}
indicatorDrawable.bounds.set(
x, y, x + indicatorDrawable.intrinsicWidth, y + indicatorDrawable.intrinsicHeight
)
}
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
if (indicatorDrawable != null && isActivated) {
if (hasIndicator && isActivated) {
indicatorDrawable.draw(canvas)
}
}

View file

@ -179,15 +179,8 @@ private fun RemoteViews.applyFullControls(context: Context, state: WidgetState):
)
)
// RemoteView is so restrictive that emulating auxio's playback icons in a sensible way is
// more or less impossible, including:
// 1. Setting foreground drawables
// 2. Applying custom image matrices
// 3. Tinting icons at all
//
// So, we have to do the dumbest possible method of duplicating each drawable and hard-coding
// indicators, tints, and icon sizes. And then google wonders why nobody uses widgets on
// android.
// Like notifications, use the remote variants of icons since we really don't want to hack
// indicators.
val shuffleRes = when {
state.isShuffled -> R.drawable.ic_remote_shuffle_on
else -> R.drawable.ic_remote_shuffle_off

View file

@ -34,6 +34,7 @@ import coil.imageLoader
import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.coil.SquareFrameTransform
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getDimenSizeSafe
@ -87,31 +88,34 @@ class WidgetProvider : AppWidgetProvider() {
}
private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
// Load our image so that it takes up the phone screen. This allows
// us to get stable rounded corners for every single widget image. This probably
// sacrifices quality in some way, but it's really the only good option.
val metrics = context.resources.displayMetrics
val imageSize = min(metrics.widthPixels, metrics.heightPixels)
val coverRequest = ImageRequest.Builder(context)
.data(song.album)
.size(imageSize)
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
// If we are on Android 12 or higher, round out the album cover.
// This is simply to maintain stylistic cohesion with other widgets.
// Here, we actually have to use RoundedCornersTransformation since the way
// we get a 1:1 aspect ratio image results in clipToOutline not working well.
// The widget has two distinct styles that we must transform the album art to accommodate:
// - Before Android 12, the widget has hard edges, so we don't need to round out the album
// art.
// - After Android 12, the widget has round edges, so we need to round out the album art.
// I dislike this, but it's mainly for stylistic cohesion.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Use RoundedCornersTransformation. This is because our hack to get a 1:1 aspect
// ratio on widget ImageViews doesn't actually result in a square ImageView, so
// clipToOutline won't work.
val transform = RoundedCornersTransformation(
context.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
.toFloat()
)
coverRequest.transformations(transform)
// The output of RoundedCornersTransformation is dimension-dependent, so scale up the
// image to the screen size to ensure consistent radii.
val metrics = context.resources.displayMetrics
coverRequest.transformations(SquareFrameTransform(), transform)
.size(min(metrics.widthPixels, metrics.heightPixels))
} else {
coverRequest.transformations(SquareFrameTransform())
}
context.imageLoader.enqueue(coverRequest.build())
@ -214,7 +218,6 @@ class WidgetProvider : AppWidgetProvider() {
// Find the layout with the greatest area that fits entirely within
// the widget. This is what we will use.
val candidates = mutableListOf<SizeF>()
for (size in views.keys) {

View file

@ -6,6 +6,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#A0ffffff"
android:fillColor="#80ffffff"
android:pathData="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</vector>

View file

@ -6,6 +6,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#A0FFFFFF"
android:fillColor="#80FFFFFF"
android:pathData="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm0.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</vector>

View file

@ -164,18 +164,16 @@
</style>
<style name="Widget.Auxio.FloatingActionButton.PlayPause" parent="Widget.Material3.FloatingActionButton.Secondary">
<item name="maxImageSize">@dimen/size_playback_icon</item>
<item name="fabCustomSize">@dimen/size_btn_large</item>
<!--
Abuse this floating action button to act more like an old-school auxio button.
This is only done because elevation acts weird with the panel layout.
This is for two reasons:
1. We upscale the play icon to 32dp, so the total FAB size also needs to increase to
compensate.
2. For some reason elevation behaves strangely in the playback panel, so we disable it.
-->
<item name="maxImageSize">@dimen/size_playback_icon</item>
<item name="fabCustomSize">@dimen/size_btn_large</item>
<item name="android:elevation">0dp</item>
<item name="elevation">0dp</item>
</style>
<style name="ShapeAppearance.Auxio.FloatingActionButton.PlayPause" parent="">
<item name="cornerSize">50%</item>
</style>
</resources>