diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt index b51735a92..67f3b82ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -85,7 +85,16 @@ private constructor(private val binding: ItemDetailHeaderBinding) : editedPlaylist: List?, listener: DetailHeaderAdapter.Listener ) { - binding.detailCover.bind(playlist, editedPlaylist) + if (editedPlaylist != null) { + logD("Binding edited playlist image") + binding.detailCover.bind( + editedPlaylist, + binding.context.getString(R.string.desc_playlist_image, playlist.name), + R.drawable.ic_playlist_24) + } else { + binding.detailCover.bind(playlist) + } + binding.detailType.text = binding.context.getString(R.string.lbl_playlist) binding.detailName.text = playlist.name.resolve(binding.context) // Nothing about a playlist is applicable to the sub-head text. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index 9f45048fc..66fc29d7c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -22,6 +22,7 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -162,31 +163,33 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) - binding.songTrack.apply { - if (song.track != null) { - // Instead of an album cover, we show the track number, as the song list - // within the album detail view would have homogeneous album covers otherwise. + val track = song.track + if (track != null) { + binding.songTrackCover.contentDescription = + binding.context.getString(R.string.desc_track_number, track) + binding.songTrackText.apply { + isVisible = true text = context.getString(R.string.fmt_number, song.track) - isInvisible = false - contentDescription = context.getString(R.string.desc_track_number, song.track) - } else { - // No track, do not show a number, instead showing a generic icon. - text = "" - isInvisible = true - contentDescription = context.getString(R.string.def_track) } + binding.songTrackPlaceholder.isInvisible = true + } else { + binding.songTrackCover.contentDescription = + binding.context.getString(R.string.def_track) + binding.songTrackText.apply { + isInvisible = true + text = null + } + binding.songTrackPlaceholder.isVisible = true } binding.songName.text = song.name.resolve(binding.context) - - // Use duration instead of album or artist for each song, as this text would - // be homogenous otherwise. + // Use duration instead of album or artist for each song to be more contextually relevant. binding.songDuration.text = song.durationMs.formatDurationMs(false) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.songTrackBg.isPlaying = isPlaying + binding.songTrackCover.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt index ea3febed1..524c27792 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt @@ -110,7 +110,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -162,7 +162,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index c6bbd14d9..06c5be29b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -256,7 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) : override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.interactBody.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } override fun updateEditing(editing: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index bd19c3a87..76a55dea1 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -97,16 +97,14 @@ constructor( ImageRequest.Builder(context) .data(listOf(song)) // Use ORIGINAL sizing, as we are not loading into any View-like component. - .size(Size.ORIGINAL) - .transformations(SquareFrameTransform.INSTANCE)) + .size(Size.ORIGINAL)) // Override the target in order to deliver the bitmap to the given // listener. + .transformations(SquareFrameTransform.INSTANCE) .target( onSuccess = { synchronized(this) { if (currentHandle == handle) { - // Has not been superseded by a new request, can deliver - // this result. target.onCompleted(it.toBitmap()) } } @@ -114,8 +112,6 @@ constructor( onError = { synchronized(this) { if (currentHandle == handle) { - // Has not been superseded by a new request, can deliver - // this result. target.onCompleted(null) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt new file mode 100644 index 000000000..103237d65 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2023 Auxio Project + * CoverView.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 . + */ + +package org.oxycblt.auxio.image + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.children +import androidx.core.view.updateMarginsRelative +import coil.ImageLoader +import coil.request.ImageRequest +import coil.util.CoilUtils +import com.google.android.material.R as MR +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 +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.ui.UISettings +import org.oxycblt.auxio.util.getAttrColorCompat +import org.oxycblt.auxio.util.getColorCompat +import org.oxycblt.auxio.util.getDimenPixels +import org.oxycblt.auxio.util.getDrawableCompat +import org.oxycblt.auxio.util.getInteger + +/** + * Auxio's extension of [ImageView] that enables cover art loading and playing indicator and + * selection badge. In practice, it's three [ImageView]'s in a [FrameLayout] trenchcoat. By default, + * all of this functionality is enabled. The playback indicator and selection badge selectively + * disabled with the "playbackIndicatorEnabled" and "selectionBadgeEnabled" attributes, and image + * 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 +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr) { + @Inject lateinit var imageLoader: ImageLoader + @Inject lateinit var uiSettings: UISettings + + private val image: ImageView + private val playbackIndicator: PlaybackIndicatorView? + private val selectionBadge: ImageView? + private val cornerRadius: Float + + private var fadeAnimator: ValueAnimator? = null + + init { + // Obtain some StyledImageView attributes to use later when theming the custom view. + @SuppressLint("CustomViewStyleable") + 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 + // view. + cornerRadius = + if (uiSettings.roundMode) { + styledAttrs.getDimension(R.styleable.CoverView_cornerRadius, 0f) + } else { + 0f + } + + val playbackIndicatorEnabled = + styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true) + + val selectionBadgeEnabled = + styledAttrs.getBoolean(R.styleable.CoverView_enableSelectionBadge, true) + + styledAttrs.recycle() + + image = ImageView(context, attrs) + + // Initialize the playback indicator if enabled. + playbackIndicator = + if (playbackIndicatorEnabled) { + PlaybackIndicatorView(context) + } else { + null + } + + // Initialize the selection badge if enabled. + selectionBadge = + if (selectionBadgeEnabled) { + ImageView(context).apply { + imageTintList = context.getAttrColorCompat(MR.attr.colorOnPrimary) + setImageResource(R.drawable.ic_check_20) + setBackgroundResource(R.drawable.ui_selection_badge_bg) + } + } else { + null + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + + // The image isn't added if other children have populated the body. This is by design. + if (childCount == 0) { + addView(image) + } + + playbackIndicator?.let(::addView) + + // Add backgrounds to each children. This creates visual consistency between each view, + // and also enables views to be hidden without clunky visibility changes. + for (child in children) { + child.apply { + // If there are rounded corners, we want to make sure view content will be cropped + // with it. + clipToOutline = true + background = + MaterialShapeDrawable().apply { + fillColor = context.getColorCompat(R.color.sel_cover_bg) + setCornerSize(cornerRadius) + } + } + } + + // The selection badge has it's own background we don't want overridden, add it after + // all other elements. + selectionBadge?.let { + addView( + it, + // Position the selection badge to the bottom right. + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + // Override the layout params of the indicator so that it's in the + // bottom left corner. + gravity = Gravity.BOTTOM or Gravity.END + val spacing = context.getDimenPixels(R.dimen.spacing_tiny) + updateMarginsRelative(bottom = spacing, end = spacing) + }) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + invalidateRootAlpha() + invalidatePlaybackIndicatorAlpha(playbackIndicator ?: return) + invalidateSelectionIndicatorAlpha(selectionBadge ?: return) + } + + override fun setSelected(selected: Boolean) { + super.setSelected(selected) + invalidateRootAlpha() + invalidatePlaybackIndicatorAlpha(playbackIndicator ?: return) + } + + override fun setActivated(activated: Boolean) { + super.setActivated(activated) + invalidateSelectionIndicatorAlpha(selectionBadge ?: return) + } + + /** + * Set if the playback indicator should be indicated ongoing or paused playback. + * + * @param playing Whether playback is ongoing or paused. + */ + fun setPlaying(playing: Boolean) { + playbackIndicator?.setPlaying(playing) + } + + private fun invalidateRootAlpha() { + alpha = if (isSelected || isEnabled) 1f else 0.5f + } + + private fun invalidatePlaybackIndicatorAlpha(playbackIndicator: ImageView) { + playbackIndicator.alpha = if (isSelected) 1f else 0f + } + + private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) { + // Set up a target transition for the selection indicator. + val targetAlpha: Float + val targetDuration: Long + + if (isActivated) { + // View is "activated" (i.e marked as selected), so show the selection indicator. + targetAlpha = 1f + targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong() + } else { + // View is not "activated", hide the selection indicator. + targetAlpha = 0f + targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong() + } + + if (selectionBadge.alpha == targetAlpha) { + // Nothing to do. + return + } + + if (!isLaidOut) { + // Not laid out, initialize it without animation before drawing. + selectionBadge.alpha = targetAlpha + return + } + + if (fadeAnimator != null) { + // Cancel any previous animation. + fadeAnimator?.cancel() + fadeAnimator = null + } + + fadeAnimator = + ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply { + duration = targetDuration + addUpdateListener { selectionBadge.alpha = it.animatedValue as Float } + start() + } + } + + /** + * Bind a [Song]'s image to this view. + * + * @param song The [Song] to bind to the view. + */ + fun bind(song: Song) = bind(song.album) + + /** + * Bind an [Album]'s image to this view. + * + * @param album The [Album] to bind to the view. + */ + fun bind(album: Album) = + bind( + album.songs, + context.getString(R.string.desc_album_cover, album.name), + R.drawable.ic_album_24) + + /** + * Bind an [Artist]'s image to this view. + * + * @param artist The [Artist] to bind to the view. + */ + fun bind(artist: Artist) = + bind( + artist.songs, + context.getString(R.string.desc_artist_image, artist.name), + R.drawable.ic_artist_24) + + /** + * Bind a [Genre]'s image to this view. + * + * @param genre The [Genre] to bind to the view. + */ + fun bind(genre: Genre) = + bind( + genre.songs, + context.getString(R.string.desc_genre_image, genre.name), + R.drawable.ic_genre_24) + + /** + * Bind a [Playlist]'s image to this view. + * + * @param playlist the [Playlist] to bind. + */ + fun bind(playlist: Playlist) = + bind( + playlist.songs, + context.getString(R.string.desc_playlist_image, playlist.name), + R.drawable.ic_playlist_24) + + /** + * Bind the covers of a generic list of [Song]s. + * + * @param songs The [Song]s to bind. + * @param desc The content description to describe the bound data. + * @param errorRes The resource of the error drawable to use if the cover cannot be loaded. + */ + fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) { + val request = + ImageRequest.Builder(context) + .data(songs) + .error(StyledDrawable(context, context.getDrawableCompat(errorRes))) + .transformations(SquareFrameTransform.INSTANCE) + .target(image) + .build() + // Dispose of any previous image request and load a new image. + CoilUtils.dispose(image) + imageLoader.enqueue(request) + contentDescription = desc + } + + private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() { + init { + // Re-tint the drawable to use the analogous "on surface" color for + // StyledImageView. + DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) + } + + 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 + inner.bounds.set( + adjustWidth, + adjustHeight, + bounds.width() - adjustWidth, + bounds.height() - adjustHeight) + inner.draw(canvas) + } + + // Required drawable overrides. Just forward to the wrapped drawable. + + override fun setAlpha(alpha: Int) { + inner.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + inner.colorFilter = colorFilter + } + + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt deleted file mode 100644 index a2a58abef..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ImageGroup.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 . - */ - -package org.oxycblt.auxio.image - -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.Gravity -import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import androidx.annotation.AttrRes -import androidx.core.view.updateMarginsRelative -import com.google.android.material.R as MR -import com.google.android.material.shape.MaterialShapeDrawable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.getAttrColorCompat -import org.oxycblt.auxio.util.getColorCompat -import org.oxycblt.auxio.util.getDimenPixels -import org.oxycblt.auxio.util.getInteger - -/** - * A super-charged [StyledImageView]. This class enables the following features in addition to - * [StyledImageView]: - * - A selection indicator - * - An activation (playback) indicator - * - Support for ONE custom view - * - * This class is primarily intended for list items. For other uses, [StyledImageView] is more - * suitable. - * - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Rework content descriptions here - * TODO: Attempt unification with StyledImageView with some kind of dynamic configuration to avoid - * superfluous elements - * TODO: Handle non-square covers by gracefully placing them in the layout - */ -class ImageGroup -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : - FrameLayout(context, attrs, defStyleAttr) { - private val innerImageView: StyledImageView - private var customView: View? = null - private val playbackIndicatorView: PlaybackIndicatorView - private val selectionIndicatorView: ImageView - - private var fadeAnimator: ValueAnimator? = null - private val cornerRadius: Float - - init { - // Obtain some StyledImageView attributes to use later when theming the custom view. - @SuppressLint("CustomViewStyleable") - val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) - // Keep track of our corner radius so that we can apply the same attributes to the custom - // view. - cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) - styledAttrs.recycle() - - // Initialize what views we can here. - innerImageView = StyledImageView(context, attrs) - playbackIndicatorView = - PlaybackIndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius } - selectionIndicatorView = - ImageView(context).apply { - imageTintList = context.getAttrColorCompat(MR.attr.colorOnPrimary) - setImageResource(R.drawable.ic_check_20) - setBackgroundResource(R.drawable.ui_selection_badge_bg) - } - - // The inner StyledImageView should be at the bottom and hidden by any other elements - // if they become visible. - addView(innerImageView) - } - - override fun onFinishInflate() { - super.onFinishInflate() - // Due to innerImageView, the max child count is actually 2 and not 1. - check(childCount < 3) { "Only one custom view is allowed" } - - // Get the second inflated child, making sure we customize it to align with - // the rest of this view. - customView = - getChildAt(1)?.apply { - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - setCornerSize(cornerRadius) - } - } - - // Playback indicator should sit above the inner StyledImageView and custom view/ - addView(playbackIndicatorView) - // Selection indicator should never be obscured, so place it at the top. - addView( - selectionIndicatorView, - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - // Override the layout params of the indicator so that it's in the - // bottom left corner. - gravity = Gravity.BOTTOM or Gravity.END - val spacing = context.getDimenPixels(R.dimen.spacing_tiny) - updateMarginsRelative(bottom = spacing, end = spacing) - }) - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - // Initialize each component before this view is drawn. - invalidateImageAlpha() - invalidatePlayingIndicator() - invalidateSelectionIndicator() - } - - override fun setActivated(activated: Boolean) { - super.setActivated(activated) - invalidateSelectionIndicator() - } - - override fun setEnabled(enabled: Boolean) { - super.setEnabled(enabled) - invalidateImageAlpha() - invalidatePlayingIndicator() - } - - override fun setSelected(selected: Boolean) { - super.setSelected(selected) - invalidateImageAlpha() - invalidatePlayingIndicator() - } - - /** - * Bind a [Song] to the internal [StyledImageView]. - * - * @param song The [Song] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(song: Song) = innerImageView.bind(song) - - /** - * Bind a [Album] to the internal [StyledImageView]. - * - * @param album The [Album] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(album: Album) = innerImageView.bind(album) - - /** - * Bind a [Genre] to the internal [StyledImageView]. - * - * @param artist The [Artist] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(artist: Artist) = innerImageView.bind(artist) - - /** - * Bind a [Genre] to the internal [StyledImageView]. - * - * @param genre The [Genre] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(genre: Genre) = innerImageView.bind(genre) - - /** - * Bind a [Playlist]'s image to the internal [StyledImageView]. - * - * @param playlist the [Playlist] to bind. - * @see StyledImageView.bind - */ - fun bind(playlist: Playlist) = innerImageView.bind(playlist) - - /** - * Whether this view should be indicated to have ongoing playback or not. See - * PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this - * view to already be marked as playing with setSelected (not the same thing) before this is set - * to true. - */ - var isPlaying: Boolean - get() = playbackIndicatorView.isPlaying - set(value) { - playbackIndicatorView.isPlaying = value - } - - private fun invalidateImageAlpha() { - // If this view is disabled, show it at half-opacity, *unless* it is also marked - // as playing, in which we still want to show it at full-opacity. - alpha = if (isSelected || isEnabled) 1f else 0.5f - } - - private fun invalidatePlayingIndicator() { - if (isSelected) { - // View is "selected" (actually marked as playing), so show the playing indicator - // and hide all other elements except for the selection indicator. - // TODO: Animate the other indicators? - customView?.alpha = 0f - innerImageView.alpha = 0f - playbackIndicatorView.alpha = 1f - } else { - // View is not "selected", hide the playing indicator. - customView?.alpha = 1f - innerImageView.alpha = 1f - playbackIndicatorView.alpha = 0f - } - } - - private fun invalidateSelectionIndicator() { - // Set up a target transition for the selection indicator. - val targetAlpha: Float - val targetDuration: Long - - if (isActivated) { - // View is "activated" (i.e marked as selected), so show the selection indicator. - targetAlpha = 1f - targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong() - } else { - // View is not "activated", hide the selection indicator. - targetAlpha = 0f - targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong() - } - - if (selectionIndicatorView.alpha == targetAlpha) { - // Nothing to do. - return - } - - if (!isLaidOut) { - // Not laid out, initialize it without animation before drawing. - selectionIndicatorView.alpha = targetAlpha - return - } - - if (fadeAnimator != null) { - // Cancel any previous animation. - fadeAnimator?.cancel() - fadeAnimator = null - } - - fadeAnimator = - ValueAnimator.ofFloat(selectionIndicatorView.alpha, targetAlpha).apply { - duration = targetDuration - addUpdateListener { selectionIndicatorView.alpha = it.animatedValue as Float } - start() - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt index 68c9bcd44..50e032bd0 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt @@ -26,12 +26,9 @@ import android.util.AttributeSet import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatImageView import androidx.core.widget.ImageViewCompat -import com.google.android.material.shape.MaterialShapeDrawable import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlin.math.max import org.oxycblt.auxio.R -import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat @@ -39,8 +36,8 @@ 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 [ImageGroup]. Due to limitations with [AnimationDrawable] - * instances within custom views, this cannot be merged with [ImageGroup]. + * 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) */ @@ -56,39 +53,16 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private val indicatorMatrix = Matrix() private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() - @Inject lateinit var uiSettings: UISettings - /** - * The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius - * to this view without any attribute hacks. - */ - var cornerRadius = 0f - set(value) { - field = value - (background as? MaterialShapeDrawable)?.let { bg -> - if (uiSettings.roundMode) { - bg.setCornerSize(value) - } else { - bg.setCornerSize(0f) - } - } - } - - /** - * Whether this view should be indicated to have ongoing playback or not. If true, the animated - * playing icon will be shown. If false, the static paused icon will be shown. - */ - var isPlaying: Boolean - get() = drawable == playingIndicatorDrawable - set(value) { - if (value) { - playingIndicatorDrawable.start() - setImageDrawable(playingIndicatorDrawable) - } else { - playingIndicatorDrawable.stop() - setImageDrawable(pausedIndicatorDrawable) - } + 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 @@ -96,19 +70,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr 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)) - - // Use clipToOutline and a background drawable to crop images. While Coil's transformation - // could theoretically be used to round corners, the corner radius is dependent on the - // dimensions of the image, which will result in inconsistent corners across different - // album covers unless we resize all covers to be the same size. clipToOutline is both - // cheaper and more elegant. As a side-note, this also allows us to re-use the same - // background for both the tonal background color and the corner rounding. - clipToOutline = true - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - setCornerSize(cornerRadius) - } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt deleted file mode 100644 index 3f732f352..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * StyledImageView.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 . - */ - -package org.oxycblt.auxio.image - -import android.content.Context -import android.graphics.Canvas -import android.graphics.ColorFilter -import android.graphics.PixelFormat -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import androidx.annotation.AttrRes -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.DrawableCompat -import coil.ImageLoader -import coil.request.ImageRequest -import coil.util.CoilUtils -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 -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.util.getColorCompat -import org.oxycblt.auxio.util.getDrawableCompat - -/** - * An [AppCompatImageView] with some additional styling, including: - * - Tonal background - * - Rounded corners based on user preferences - * - Built-in support for binding image data or using a static icon with the same styling as - * placeholder drawables. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class StyledImageView -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : - AppCompatImageView(context, attrs, defStyleAttr) { - @Inject lateinit var imageLoader: ImageLoader - @Inject lateinit var uiSettings: UISettings - - init { - // Load view attributes - val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) - val staticIcon = - styledAttrs.getResourceId( - R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL) - val cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) - styledAttrs.recycle() - - if (staticIcon != ResourcesCompat.ID_NULL) { - // Use the static icon if specified for this image. - setImageDrawable(StyledDrawable(context, context.getDrawableCompat(staticIcon))) - } - - // Use clipToOutline and a background drawable to crop images. While Coil's transformation - // could theoretically be used to round corners, the corner radius is dependent on the - // dimensions of the image, which will result in inconsistent corners across different - // album covers unless we resize all covers to be the same size. clipToOutline is both - // cheaper and more elegant. As a side-note, this also allows us to re-use the same - // background for both the tonal background color and the corner rounding. - clipToOutline = true - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - if (uiSettings.roundMode) { - // Only use the specified corner radius when round mode is enabled. - setCornerSize(cornerRadius) - } - } - } - - /** - * Bind a [Song]'s album cover to this view, also updating the content description. - * - * @param song The [Song] to bind. - */ - fun bind(song: Song) = bind(song.album) - - /** - * Bind an [Album]'s cover to this view, also updating the content description. - * - * @param album the [Album] to bind. - */ - fun bind(album: Album) = bind(album, R.drawable.ic_album_24, R.string.desc_album_cover) - - /** - * Bind an [Artist]'s image to this view, also updating the content description. - * - * @param artist the [Artist] to bind. - */ - fun bind(artist: Artist) = bind(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) - - /** - * Bind an [Genre]'s image to this view, also updating the content description. - * - * @param genre the [Genre] to bind. - */ - fun bind(genre: Genre) = bind(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) - - /** - * Bind a [Playlist]'s image to this view, also updating the content description. - * - * @param playlist The [Playlist] to bind. - * @param songs [Song]s that can override the playlist image if it needs to differ for any - * reason. - */ - fun bind(playlist: Playlist, songs: List? = null) = - if (songs != null) { - bind( - songs, - context.getString(R.string.desc_playlist_image, playlist.name.resolve(context)), - R.drawable.ic_playlist_24) - } else { - bind(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image) - } - - private fun bind(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) { - bind(parent.songs, context.getString(descRes, parent.name.resolve(context)), errorRes) - } - - private fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) { - val request = - ImageRequest.Builder(context) - .data(songs) - .error(StyledDrawable(context, context.getDrawableCompat(errorRes))) - .transformations(SquareFrameTransform.INSTANCE) - .target(this) - .build() - // Dispose of any previous image request and load a new image. - CoilUtils.dispose(this) - imageLoader.enqueue(request) - contentDescription = desc - } - - /** - * A [Drawable] wrapper that re-styles the drawable to better align with the style of - * [StyledImageView]. - * - * @param context [Context] required for initialization. - * @param inner The [Drawable] to wrap. - */ - private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() { - init { - // Re-tint the drawable to use the analogous "on surface" color for - // StyledImageView. - DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) - } - - 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 - inner.bounds.set( - adjustWidth, - adjustHeight, - bounds.width() - adjustWidth, - bounds.height() - adjustHeight) - inner.draw(canvas) - } - - // Required drawable overrides. Just forward to the wrapped drawable. - - override fun setAlpha(alpha: Int) { - inner.alpha = alpha - } - - override fun setColorFilter(colorFilter: ColorFilter?) { - inner.colorFilter = colorFilter - } - - override fun getOpacity(): Int = PixelFormat.TRANSLUCENT - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index c835f5c63..be332de20 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -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 @@ -252,4 +253,21 @@ constructor( val size = pxOrElse { 512 } return if (size.mod(2) > 0) size + 1 else size } + + private 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 + } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 3378e6400..c829248e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -64,7 +64,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -114,7 +114,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -174,7 +174,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -231,7 +231,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -288,7 +288,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 501b58af8..350ec3daa 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -167,7 +167,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.interactBody.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } companion object { diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index a7406768f..f2919eacc 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,15 +16,16 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false" + tools:ignore="ContentDescription" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false" + tools:ignore="ContentDescription" /> - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - + tools:ignore="ContentDescription" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false" + tools:ignore="ContentDescription" /> - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintVertical_chainStyle="packed" /> diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 2505dcf32..3d495dd40 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -16,15 +16,25 @@ with us only overlaying the track number (and other elements) onto it. --> - + app:layout_constraintTop_toTopOf="parent"> + + - + @@ -68,9 +78,9 @@ android:layout_height="wrap_content" android:layout_marginEnd="@dimen/spacing_mid_medium" android:textColor="?android:attr/textColorSecondary" - app:layout_constraintBottom_toBottomOf="@+id/song_track_bg" + app:layout_constraintBottom_toBottomOf="@+id/song_track_cover" app:layout_constraintEnd_toStartOf="@+id/song_menu" - app:layout_constraintStart_toEndOf="@+id/song_track_bg" + app:layout_constraintStart_toEndOf="@+id/song_track_cover" app:layout_constraintTop_toBottomOf="@+id/song_name" tools:text="16:16" /> diff --git a/app/src/main/res/layout/item_detail_header.xml b/app/src/main/res/layout/item_detail_header.xml index 99c4e17d2..56c756f38 100644 --- a/app/src/main/res/layout/item_detail_header.xml +++ b/app/src/main/res/layout/item_detail_header.xml @@ -10,14 +10,15 @@ android:paddingEnd="@dimen/spacing_medium" android:paddingBottom="@dimen/spacing_mid_medium"> - + tools:ignore="ContentDescription" /> - + tools:ignore="ContentDescription"> + + + + @@ -43,7 +55,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" tools:visibility="gone" - app:layout_constraintStart_toEndOf="@+id/disc_icon" + app:layout_constraintStart_toEndOf="@+id/disc_cover" app:layout_constraintTop_toBottomOf="@+id/disc_number" tools:text="Part 1" /> diff --git a/app/src/main/res/layout/item_editable_song.xml b/app/src/main/res/layout/item_editable_song.xml index 93fe6f0de..dbda5a44a 100644 --- a/app/src/main/res/layout/item_editable_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -34,7 +34,7 @@ android:layout_height="wrap_content" android:background="@drawable/ui_item_ripple"> - + app:layout_constraintTop_toTopOf="parent" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false"> + + + + - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="parent" /> 200 100 - + - - + + diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index acc55daa7..3478f99b0 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -47,21 +47,18 @@ @dimen/size_cover_large @dimen/size_cover_large @dimen/size_corners_medium - true