image: introduce sizing

Unify corner radius and icon size configurations under a new sizing
property. This is largely driven by a need to remove the typical
half-width icon sizing, as it results in blurry playing indicators
in some cases. This also co-incides with a change of parent image
icon sizes to 32dp over 28dp.

Resolves #415.
This commit is contained in:
Alexander Capehart 2023-05-28 19:51:06 -06:00
parent 47b791b95f
commit b93a512bf7
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 116 additions and 140 deletions

View file

@ -9,6 +9,7 @@
- Tracks with no disc number now default to "No Disc" instead of "Disc 1" - Tracks with no disc number now default to "No Disc" instead of "Disc 1"
- Albums implicitly linked only via "artist" tags are now placed in a special - Albums implicitly linked only via "artist" tags are now placed in a special
"appears on" section in the artist view "appears on" section in the artist view
- Album covers that are not 1:1 aspect ratio are no longer cropped
#### What's Fixed #### What's Fixed
- Prevented options such as "Add to queue" from being selected on empty artists and playlists - Prevented options such as "Add to queue" from being selected on empty artists and playlists

View file

@ -23,17 +23,23 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.ColorFilter import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.AnimationDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.res.getIntOrThrow
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.updateMarginsRelative import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.util.CoilUtils import coil.util.CoilUtils
@ -50,6 +56,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.getInteger import org.oxycblt.auxio.util.getInteger
@ -72,29 +79,34 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Inject lateinit var uiSettings: UISettings @Inject lateinit var uiSettings: UISettings
private val image: ImageView private val image: ImageView
private val playbackIndicator: PlaybackIndicatorView?
data class PlaybackIndicator(
val view: ImageView,
val playingDrawable: AnimationDrawable,
val pausedDrawable: Drawable
)
private val playbackIndicator: PlaybackIndicator?
private val selectionBadge: ImageView? private val selectionBadge: ImageView?
private val cornerRadius: Float
@DimenRes private val iconSizeRes: Int?
@DimenRes private val cornerRadiusRes: Int
private var fadeAnimator: ValueAnimator? = null private var fadeAnimator: ValueAnimator? = null
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
init { init {
// Obtain some StyledImageView attributes to use later when theming the custom view. // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView) val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
// Keep track of our corner radius so that we can apply the same attributes to the custom val sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
// view. iconSizeRes = SIZING_ICON_SIZE[sizing]
cornerRadius = cornerRadiusRes = SIZING_CORNER_RADII[sizing]
if (uiSettings.roundMode) {
styledAttrs.getDimension(R.styleable.CoverView_cornerRadius, 0f)
} else {
0f
}
val playbackIndicatorEnabled = val playbackIndicatorEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true) styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
val selectionBadgeEnabled = val selectionBadgeEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enableSelectionBadge, true) styledAttrs.getBoolean(R.styleable.CoverView_enableSelectionBadge, true)
@ -105,7 +117,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Initialize the playback indicator if enabled. // Initialize the playback indicator if enabled.
playbackIndicator = playbackIndicator =
if (playbackIndicatorEnabled) { if (playbackIndicatorEnabled) {
PlaybackIndicatorView(context) PlaybackIndicator(
ImageView(context).apply {
scaleType = ImageView.ScaleType.MATRIX
ImageViewCompat.setImageTintList(
this, context.getColorCompat(R.color.sel_on_cover_bg))
},
context.getDrawableCompat(R.drawable.ic_playing_indicator_24)
as AnimationDrawable,
context.getDrawableCompat(R.drawable.ic_paused_indicator_24))
} else { } else {
null null
} }
@ -131,10 +151,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
addView(image) addView(image)
} }
playbackIndicator?.let(::addView) playbackIndicator?.run { addView(view) }
// Add backgrounds to each children. This creates visual consistency between each view, // Add backgrounds to each child for visual consistency
// and also enables views to be hidden without clunky visibility changes.
for (child in children) { for (child in children) {
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
@ -143,7 +162,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background = background =
MaterialShapeDrawable().apply { MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg) fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadius) setCornerSize(context.getDimen(cornerRadiusRes))
} }
} }
} }
@ -164,6 +183,36 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
// behavior with a matrix.
val playbackIndicator = (playbackIndicator ?: return).view
val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
playbackIndicator.apply {
imageMatrix =
indicatorMatrix.apply {
reset()
drawable?.let { drawable ->
// First scale the icon up to the desired size.
indicatorMatrixSrc.set(
0f,
0f,
drawable.intrinsicWidth.toFloat(),
drawable.intrinsicHeight.toFloat())
indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon.
indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
}
}
}
}
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
invalidateRootAlpha() invalidateRootAlpha()
@ -185,17 +234,25 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Set if the playback indicator should be indicated ongoing or paused playback. * Set if the playback indicator should be indicated ongoing or paused playback.
* *
* @param playing Whether playback is ongoing or paused. * @param isPlaying Whether playback is ongoing or paused.
*/ */
fun setPlaying(playing: Boolean) { fun setPlaying(isPlaying: Boolean) {
playbackIndicator?.setPlaying(playing) playbackIndicator?.run {
if (isPlaying) {
playingDrawable.start()
view.setImageDrawable(playingDrawable)
} else {
playingDrawable.stop()
view.setImageDrawable(pausedDrawable)
}
}
} }
private fun invalidateRootAlpha() { private fun invalidateRootAlpha() {
alpha = if (isSelected || isEnabled) 1f else 0.5f alpha = if (isSelected || isEnabled) 1f else 0.5f
} }
private fun invalidatePlaybackIndicatorAlpha(playbackIndicator: ImageView) { private fun invalidatePlaybackIndicatorAlpha(playbackIndicator: PlaybackIndicator) {
// Occasionally content can bleed through the rounded corners and result in a seam // Occasionally content can bleed through the rounded corners and result in a seam
// on the playing indicator, prevent that from occurring by disabling the visibility of // on the playing indicator, prevent that from occurring by disabling the visibility of
// all views below the playback indicator. // all views below the playback indicator.
@ -204,7 +261,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
when (child) { when (child) {
// Selection badge is above the playback indicator, do nothing // Selection badge is above the playback indicator, do nothing
selectionBadge -> child.alpha selectionBadge -> child.alpha
playbackIndicator -> if (isSelected) 1f else 0f playbackIndicator.view -> if (isSelected) 1f else 0f
else -> if (isSelected) 0f else 1f else -> if (isSelected) 0f else 1f
} }
} }
@ -312,8 +369,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val request = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(songs) .data(songs)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes))) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.transformations(RoundedCornersTransformation(cornerRadius)) .transformations(RoundedCornersTransformation(context.getDimen(cornerRadiusRes)))
.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.
@ -322,23 +379,28 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
contentDescription = desc contentDescription = desc
} }
private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() { /**
* Since the error drawable must also share a view with an image, any kind of transform or tint
* must occur within a custom dialog, which is implemented here.
*/
private class StyledDrawable(
context: Context,
private val inner: Drawable,
@DimenRes iconSizeRes: Int?
) : Drawable() {
init { init {
// Re-tint the drawable to use the analogous "on surface" color for // Re-tint the drawable to use the analogous "on surface" color for
// StyledImageView. // StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
} }
private val dimen = iconSizeRes?.let(context::getDimenPixels)
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 = inner.bounds.width() / 4 val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
val adjustHeight = inner.bounds.height() / 4 inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
inner.bounds.set(
adjustWidth,
adjustHeight,
bounds.width() - adjustWidth,
bounds.height() - adjustHeight)
inner.draw(canvas) inner.draw(canvas)
} }
@ -354,4 +416,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
} }
companion object {
val SIZING_CORNER_RADII =
arrayOf(
R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium)
val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null)
}
} }

View file

@ -1,100 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* PlaybackIndicatorView.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.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.AnimationDrawable
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.widget.ImageViewCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/**
* A view that displays an activation (i.e playback) indicator, with an accented styling and an
* animated equalizer icon.
*
* This is only meant for use with [CoverView]. Due to limitations with [AnimationDrawable]
* instances within custom views, this cannot be merged with [CoverView].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class PlaybackIndicatorView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) {
private val playingIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable
private val pausedIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_paused_indicator_24)
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
fun setPlaying(isPlaying: Boolean) {
if (isPlaying) {
playingIndicatorDrawable.start()
setImageDrawable(playingIndicatorDrawable)
} else {
playingIndicatorDrawable.stop()
setImageDrawable(pausedIndicatorDrawable)
}
}
init {
// We will need to manually re-scale the playing/paused drawables to align with
// StyledDrawable, so use the matrix scale type.
scaleType = ScaleType.MATRIX
// Tint the playing/paused drawables so they are harmonious with the background.
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Emulate StyledDrawable scaling with matrix scaling.
val iconSize = max(measuredWidth, measuredHeight) / 2
imageMatrix =
indicatorMatrix.apply {
reset()
drawable?.let { drawable ->
// First scale the icon up to the desired size.
indicatorMatrixSrc.set(
0f,
0f,
drawable.intrinsicWidth.toFloat(),
drawable.intrinsicHeight.toFloat())
indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon.
indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
}
}
}
}

View file

@ -12,6 +12,11 @@
<declare-styleable name="CoverView"> <declare-styleable name="CoverView">
<attr name="cornerRadius" format="dimension" /> <attr name="cornerRadius" format="dimension" />
<attr name="sizing" format="enum">
<enum name="small" value="0" />
<enum name="medium" value="1" />
<enum name="large" value="2" />
</attr>
<attr name="enablePlaybackIndicator" format="boolean" /> <attr name="enablePlaybackIndicator" format="boolean" />
<attr name="enableSelectionBadge" format="boolean" /> <attr name="enableSelectionBadge" format="boolean" />
</declare-styleable> </declare-styleable>

View file

@ -20,13 +20,14 @@
<dimen name="size_corners_medium">16dp</dimen> <dimen name="size_corners_medium">16dp</dimen>
<dimen name="size_corners_mid_large">24dp</dimen> <dimen name="size_corners_mid_large">24dp</dimen>
<dimen name="size_btn">48dp</dimen> <dimen name="size_btn">48dp</dimen>
<dimen name="size_accent_item">56dp</dimen> <dimen name="size_accent_item">56dp</dimen>
<dimen name="size_bottom_sheet_bar">64dp</dimen> <dimen name="size_bottom_sheet_bar">64dp</dimen>
<dimen name="size_play_pause_button">72dp</dimen> <dimen name="size_play_pause_button">72dp</dimen>
<dimen name="size_icon_small">24dp</dimen> <dimen name="size_icon_small">24dp</dimen>
<dimen name="size_icon_large">32dp</dimen> <dimen name="size_icon_medium">32dp</dimen>
<dimen name="size_pre_amp_ticker">56dp</dimen> <dimen name="size_pre_amp_ticker">56dp</dimen>

View file

@ -34,38 +34,38 @@
<style name="Widget.Auxio.Image.Small" parent=""> <style name="Widget.Auxio.Image.Small" parent="">
<item name="android:layout_width">@dimen/size_cover_compact</item> <item name="android:layout_width">@dimen/size_cover_compact</item>
<item name="android:layout_height">@dimen/size_cover_compact</item> <item name="android:layout_height">@dimen/size_cover_compact</item>
<item name="cornerRadius">@dimen/size_corners_small</item> <item name="sizing">small</item>
</style> </style>
<style name="Widget.Auxio.Image.Medium" parent=""> <style name="Widget.Auxio.Image.Medium" parent="">
<item name="android:layout_width">@dimen/size_cover_normal</item> <item name="android:layout_width">@dimen/size_cover_normal</item>
<item name="android:layout_height">@dimen/size_cover_normal</item> <item name="android:layout_height">@dimen/size_cover_normal</item>
<item name="cornerRadius">@dimen/size_corners_small</item> <item name="sizing">medium</item>
</style> </style>
<style name="Widget.Auxio.Image.Large" parent=""> <style name="Widget.Auxio.Image.Large" parent="">
<item name="android:layout_width">@dimen/size_cover_large</item> <item name="android:layout_width">@dimen/size_cover_large</item>
<item name="android:layout_height">@dimen/size_cover_large</item> <item name="android:layout_height">@dimen/size_cover_large</item>
<item name="cornerRadius">@dimen/size_corners_medium</item> <item name="sizing">large</item>
</style> </style>
<style name="Widget.Auxio.Image.MidHuge" parent=""> <style name="Widget.Auxio.Image.MidHuge" parent="">
<item name="android:layout_width">@dimen/size_cover_mid_huge</item> <item name="android:layout_width">@dimen/size_cover_mid_huge</item>
<item name="android:layout_height">@dimen/size_cover_mid_huge</item> <item name="android:layout_height">@dimen/size_cover_mid_huge</item>
<item name="cornerRadius">@dimen/size_corners_medium</item> <item name="sizing">large</item>
</style> </style>
<style name="Widget.Auxio.Image.Huge" parent=""> <style name="Widget.Auxio.Image.Huge" parent="">
<item name="android:layout_width">@dimen/size_cover_huge</item> <item name="android:layout_width">@dimen/size_cover_huge</item>
<item name="android:layout_height">@dimen/size_cover_huge</item> <item name="android:layout_height">@dimen/size_cover_huge</item>
<item name="cornerRadius">@dimen/size_corners_medium</item> <item name="sizing">large</item>
</style> </style>
<style name="Widget.Auxio.Image.Full" parent=""> <style name="Widget.Auxio.Image.Full" parent="">
<item name="android:layout_width">0dp</item> <item name="android:layout_width">0dp</item>
<item name="android:layout_height">0dp</item> <item name="android:layout_height">0dp</item>
<item name="layout_constraintDimensionRatio">1</item> <item name="layout_constraintDimensionRatio">1</item>
<item name="cornerRadius">@dimen/size_corners_medium</item> <item name="sizing">large</item>
</style> </style>
<style name="Widget.Auxio.RecyclerView.Linear" parent=""> <style name="Widget.Auxio.RecyclerView.Linear" parent="">
@ -221,7 +221,7 @@
</style> </style>
<style name="Widget.Auxio.Button.Icon.Large" parent="Widget.Auxio.Button.Icon.Base"> <style name="Widget.Auxio.Button.Icon.Large" parent="Widget.Auxio.Button.Icon.Base">
<item name="iconSize">@dimen/size_icon_large</item> <item name="iconSize">@dimen/size_icon_medium</item>
<item name="android:minWidth">@dimen/size_btn</item> <item name="android:minWidth">@dimen/size_btn</item>
<item name="android:minHeight">@dimen/size_btn</item> <item name="android:minHeight">@dimen/size_btn</item>
<item name="android:insetTop">0dp</item> <item name="android:insetTop">0dp</item>
@ -238,7 +238,7 @@
<style name="Widget.Auxio.Button.PlayPause" parent="Widget.Material3.Button.IconButton.Filled.Tonal"> <style name="Widget.Auxio.Button.PlayPause" parent="Widget.Material3.Button.IconButton.Filled.Tonal">
<item name="android:minWidth">@dimen/size_play_pause_button</item> <item name="android:minWidth">@dimen/size_play_pause_button</item>
<item name="android:minHeight">@dimen/size_play_pause_button</item> <item name="android:minHeight">@dimen/size_play_pause_button</item>
<item name="iconSize">@dimen/size_icon_large</item> <item name="iconSize">@dimen/size_icon_medium</item>
<item name="android:insetTop">0dp</item> <item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item> <item name="android:insetBottom">0dp</item>
<item name="android:insetLeft">0dp</item> <item name="android:insetLeft">0dp</item>