diff --git a/CHANGELOG.md b/CHANGELOG.md index f63629126..59e2dbd8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,14 @@ - Made the layout of album songs more similar to other songs #### Dev/Meta -- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese ] +- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese] - Switched to spotless and ktfmt instead of ktlint - Migrated constants to centralized table - Introduced new RecyclerView framework - Use native ExoPlayer AudioFocus implementation - Make ReplayGain functionality use AudioProcessor instead of volume - Removed databinding [Greatly reduces compile times] -- A bunch of internal view implementation improvements +- An uncountable amount of internal codebase improvements ## v2.2.2 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index e71d0908f..bcd7e8a08 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -44,7 +44,9 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * * TODO: Rework menus [perhaps add multi-select] * - * TODO: Rework navigation to be based on a viewmodel + * TODO: Rework some fragments to use listeners *even more* + * + * TODO: Unify all member variables under an m prefix */ class MainActivity : AppCompatActivity() { private val playbackModel: PlaybackViewModel by viewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt index 485e7d6ad..0ae1b8ef3 100644 --- a/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/accent/AccentAdapter.kt @@ -27,7 +27,6 @@ import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getColorSafe -import org.oxycblt.auxio.util.getViewHolderAt import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.stateList @@ -55,7 +54,8 @@ class AccentAdapter(listener: Listener) : if (accent == selectedAccent) return selectedAccent = accent selectedViewHolder?.setSelected(false) - selectedViewHolder = recycler.getViewHolderAt(accent.index) as AccentViewHolder? + selectedViewHolder = + recycler.findViewHolderForAdapterPosition(accent.index) as AccentViewHolder? selectedViewHolder?.setSelected(true) } diff --git a/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt index fd008365b..b2fa042f9 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt @@ -51,7 +51,9 @@ import org.oxycblt.auxio.util.logW /** * The base implementation for all image fetchers in Auxio. - * @author OxygenCobalt TODO: Artist images + * @author OxygenCobalt + * + * TODO: Artist images */ abstract class BaseFetcher : Fetcher { private val settingsManager = SettingsManager.getInstance() @@ -86,7 +88,7 @@ abstract class BaseFetcher : Fetcher { // for a manual parser. // However, Samsung seems to cripple this class as to force people to use their ad-infested // music app which relies on proprietary OneUI extensions instead of AOSP. That means - // we have to have another layer of redundancy to retain quality. Thanks samsung. Prick. + // we have to have another layer of redundancy to retain quality. Thanks Samsung. Prick. val result = fetchAospMetadataCovers(context, album) if (result != null) { return result diff --git a/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt index 54293d2f8..213ef24f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt @@ -119,25 +119,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr // eventually /** Bind the album cover for a [song]. */ -fun StyledImageView.bindAlbumCover(song: Song?) = +fun StyledImageView.bindAlbumCover(song: Song) = load(song, R.drawable.ic_song, R.string.desc_album_cover) /** Bind the album cover for an [album]. */ -fun StyledImageView.bindAlbumCover(album: Album?) = +fun StyledImageView.bindAlbumCover(album: Album) = load(album, R.drawable.ic_album, R.string.desc_album_cover) /** Bind the image for an [artist] */ -fun StyledImageView.bindArtistImage(artist: Artist?) = +fun StyledImageView.bindArtistImage(artist: Artist) = load(artist, R.drawable.ic_artist, R.string.desc_artist_image) /** Bind the image for a [genre] */ -fun StyledImageView.bindGenreImage(genre: Genre?) = +fun StyledImageView.bindGenreImage(genre: Genre) = load(genre, R.drawable.ic_genre, R.string.desc_genre_image) -fun StyledImageView.load(music: T?, @DrawableRes error: Int, @StringRes desc: Int) { - contentDescription = context.getString(desc, music?.resolvedName) +fun StyledImageView.load(music: T, @DrawableRes error: Int, @StringRes desc: Int) { + contentDescription = context.getString(desc, music.resolveName(context)) dispose() - scaleType = ImageView.ScaleType.FIT_CENTER load(music) { error(error) transformations(SquareFrameTransform.INSTANCE) @@ -145,7 +144,7 @@ fun StyledImageView.load(music: T?, @DrawableRes error: Int, @String onSuccess = { _, _ -> // Using the matrix scale type will shrink the cover images, so set it back to // the default scale type. - scaleType = ImageView.ScaleType.CENTER + scaleType = ImageView.ScaleType.FIT_CENTER }, onError = { _, _ -> // Error icons need to be scaled correctly, so set it to the custom matrix diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt index 2c6880460..4309a0e9a 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt @@ -64,7 +64,7 @@ abstract class DetailFragment : ViewBindingFragment() { onMenuClick: ((itemId: Int) -> Boolean)? = null ) { requireBinding().detailToolbar.apply { - title = data.resolvedName + title = data.resolveName(context) if (menuId != -1) { inflateMenu(menuId) @@ -93,9 +93,6 @@ abstract class DetailFragment : ViewBindingFragment() { ) { logD("Launching menu") - // Scrolling breaks the menus, so we stop any momentum currently going on. - requireBinding().detailRecycler.stopScroll() - PopupMenu(anchor.context, anchor).apply { inflate(R.menu.menu_detail_sort) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index 5c6f063a1..b97c9f37f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.SimpleItemCallback +import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.textSafe @@ -119,10 +120,10 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite override fun bind(item: Album, listener: AlbumDetailAdapter.Listener) { binding.detailCover.bindAlbumCover(item) - binding.detailName.textSafe = item.resolvedName + binding.detailName.textSafe = item.resolveName(binding.context) binding.detailSubhead.apply { - textSafe = item.resolvedArtistName + textSafe = item.artist.resolveName(context) setOnClickListener { listener.onNavigateToArtist() } } @@ -152,8 +153,8 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Album, newItem: Album) = - oldItem.resolvedName == newItem.resolvedName && - oldItem.resolvedArtistName == newItem.resolvedArtistName && + oldItem.rawName == newItem.rawName && + oldItem.artist.rawName == newItem.artist.rawName && oldItem.year == newItem.year && oldItem.songs.size == newItem.songs.size && oldItem.totalDuration == newItem.totalDuration @@ -183,7 +184,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA binding.songTrackBg.imageAlpha = 255 } - binding.songName.textSafe = item.resolvedName + binding.songName.textSafe = item.resolveName(binding.context) binding.songDuration.textSafe = item.seconds.toDuration(false) binding.root.apply { @@ -214,8 +215,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Song, newItem: Song) = - oldItem.resolvedName == newItem.resolvedName && - oldItem.duration == newItem.duration + oldItem.rawName == newItem.rawName && oldItem.duration == newItem.duration } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 0225ffa40..1e895089e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -140,12 +140,16 @@ private class ArtistDetailViewHolder private constructor(private val binding: It override fun bind(item: Artist, listener: DetailAdapter.Listener) { binding.detailCover.bindArtistImage(item) - binding.detailName.textSafe = item.resolvedName + binding.detailName.textSafe = item.resolveName(binding.context) // Get the genre that corresponds to the most songs in this artist, which would be // the most "Prominent" genre. binding.detailSubhead.textSafe = - item.songs.groupBy { it.genre.resolvedName }.entries.maxByOrNull { it.value.size }?.key + item.songs + .groupBy { it.genre.resolveName(binding.context) } + .entries + .maxByOrNull { it.value.size } + ?.key ?: binding.context.getString(R.string.def_genre) binding.detailInfo.textSafe = @@ -178,7 +182,7 @@ private constructor( ) : BindingViewHolder(binding.root), Highlightable { override fun bind(item: Album, listener: MenuItemListener) { binding.parentImage.bindAlbumCover(item) - binding.parentName.textSafe = item.resolvedName + binding.parentName.textSafe = item.resolveName(binding.context) binding.parentInfo.textSafe = if (item.year != null) { binding.context.getString(R.string.fmt_number, item.year) @@ -212,7 +216,7 @@ private constructor( val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Album, newItem: Album) = - oldItem.resolvedName == newItem.resolvedName && oldItem.year == newItem.year + oldItem.rawName == newItem.rawName && oldItem.year == newItem.year } } } @@ -223,8 +227,8 @@ private constructor( ) : BindingViewHolder(binding.root), Highlightable { override fun bind(item: Song, listener: MenuItemListener) { binding.songAlbumCover.bindAlbumCover(item) - binding.songName.textSafe = item.resolvedName - binding.songInfo.textSafe = item.resolvedAlbumName + binding.songName.textSafe = item.resolveName(binding.context) + binding.songInfo.textSafe = item.album.resolveName(binding.context) binding.root.apply { setOnClickListener { listener.onItemClick(item) } @@ -252,8 +256,8 @@ private constructor( val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Song, newItem: Song) = - oldItem.resolvedName == newItem.resolvedName && - oldItem.resolvedAlbumName == newItem.resolvedAlbumName + oldItem.rawName == newItem.rawName && + oldItem.album.rawName == newItem.album.rawName } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 6e1518710..8c4b3b927 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -34,7 +34,6 @@ import org.oxycblt.auxio.ui.MultiAdapter import org.oxycblt.auxio.ui.NewHeaderViewHolder import org.oxycblt.auxio.ui.SimpleItemCallback import org.oxycblt.auxio.util.context -import org.oxycblt.auxio.util.getViewHolderAt import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.textSafe @@ -60,7 +59,7 @@ abstract class DetailAdapter( // Check if the ViewHolder for this song is visible, if it is then highlight it. // If the ViewHolder is not visible, then the adapter should take care of it if // it does become visible. - val viewHolder = recycler.getViewHolderAt(pos) + val viewHolder = recycler.findViewHolderForAdapterPosition(pos) return if (viewHolder is Highlightable) { viewHolder.setHighlighted(true) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt index 0c56656a0..5abd610e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt @@ -114,7 +114,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite BindingViewHolder(binding.root) { override fun bind(item: Genre, listener: DetailAdapter.Listener) { binding.detailCover.bindGenreImage(item) - binding.detailName.textSafe = item.resolvedName + binding.detailName.textSafe = item.resolveName(binding.context) binding.detailSubhead.textSafe = binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) binding.detailInfo.textSafe = item.totalDuration @@ -135,7 +135,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Genre, newItem: Genre) = - oldItem.resolvedName == newItem.resolvedName && + oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size && oldItem.totalDuration == newItem.totalDuration } @@ -146,8 +146,8 @@ class GenreSongViewHolder private constructor(private val binding: ItemSongBindi BindingViewHolder(binding.root), Highlightable { override fun bind(item: Song, listener: MenuItemListener) { binding.songAlbumCover.bindAlbumCover(item) - binding.songName.textSafe = item.resolvedName - binding.songInfo.textSafe = item.resolvedArtistName + binding.songName.textSafe = item.resolveName(binding.context) + binding.songInfo.textSafe = item.resolveIndividualArtistName(binding.context) binding.root.apply { setOnClickListener { listener.onItemClick(item) } setOnLongClickListener { view -> diff --git a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt index 071a32518..3c03f4553 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt @@ -27,6 +27,7 @@ import org.oxycblt.auxio.util.logD * - On small screens, use only an icon * - On medium screens, use only text * - On large screens, use text and an icon + * @author OxygenCobalt */ class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) : TabLayoutMediator.TabConfigurationStrategy { diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index 339e78a87..0d18b4389 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -29,7 +29,6 @@ import android.view.ViewGroup import android.view.WindowInsets import android.widget.FrameLayout import androidx.annotation.AttrRes -import androidx.core.view.isInvisible import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -211,6 +210,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight) + // Get the popup text. If there is none, we default to "?". val firstPos = firstAdapterPos val popupText = if (firstPos != NO_POSITION) { @@ -218,60 +218,53 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } else { null } + ?: "?" - popupView.isInvisible = popupText == null + val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams - if (popupText != null) { - val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams + if (popupView.text != popupText) { + popupView.text = popupText - if (popupView.text != popupText) { - popupView.text = popupText + val widthMeasureSpec = + ViewGroup.getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + thumbPadding.left + + thumbPadding.right + + thumbWidth + + popupLayoutParams.leftMargin + + popupLayoutParams.rightMargin, + popupLayoutParams.width) - val widthMeasureSpec = - ViewGroup.getChildMeasureSpec( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - thumbPadding.left + - thumbPadding.right + - thumbWidth + - popupLayoutParams.leftMargin + - popupLayoutParams.rightMargin, - popupLayoutParams.width) + val heightMeasureSpec = + ViewGroup.getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + thumbPadding.top + + thumbPadding.bottom + + popupLayoutParams.topMargin + + popupLayoutParams.bottomMargin, + popupLayoutParams.height) - val heightMeasureSpec = - ViewGroup.getChildMeasureSpec( - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), - thumbPadding.top + - thumbPadding.bottom + - popupLayoutParams.topMargin + - popupLayoutParams.bottomMargin, - popupLayoutParams.height) + popupView.measure(widthMeasureSpec, heightMeasureSpec) + } - popupView.measure(widthMeasureSpec, heightMeasureSpec) + val popupWidth = popupView.measuredWidth + val popupHeight = popupView.measuredHeight + val popupLeft = + if (layoutDirection == View.LAYOUT_DIRECTION_RTL) { + thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin + } else { + width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth } - val popupWidth = popupView.measuredWidth - val popupHeight = popupView.measuredHeight - val popupLeft = - if (layoutDirection == View.LAYOUT_DIRECTION_RTL) { - thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin - } else { - width - - thumbPadding.right - - thumbWidth - - popupLayoutParams.rightMargin - - popupWidth - } + val popupAnchorY = popupHeight / 2 + val thumbAnchorY = thumbView.paddingTop - val popupAnchorY = popupHeight / 2 - val thumbAnchorY = thumbView.paddingTop + val popupTop = + (thumbTop + thumbAnchorY - popupAnchorY).clamp( + thumbPadding.top + popupLayoutParams.topMargin, + height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight) - val popupTop = - (thumbTop + thumbAnchorY - popupAnchorY).clamp( - thumbPadding.top + popupLayoutParams.topMargin, - height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight) - - popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight) - } + popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight) } override fun onScrolled(dx: Int, dy: Int) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 964ba6ee6..42f11cfd7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -30,7 +30,6 @@ import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.newMenu -import org.oxycblt.auxio.ui.sliceArticle import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -55,13 +54,13 @@ class AlbumListFragment : HomeListFragment() { // Change how we display the popup depending on the mode. return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) { // By Name -> Use Name - is Sort.ByName -> album.resolvedName.sliceArticle().first().uppercase() + is Sort.ByName -> album.sortName.first().uppercase() // By Artist -> Use Artist Name - is Sort.ByArtist -> album.artist.resolvedName.sliceArticle().first().uppercase() + is Sort.ByArtist -> album.artist.sortName?.run { first().uppercase() } // Year -> Use Full Year - is Sort.ByYear -> album.year?.toString() ?: getString(R.string.def_date) + is Sort.ByYear -> album.year?.toString() // Unsupported sort, error gracefully else -> null diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 8759991eb..4e703076c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -28,7 +28,6 @@ import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.newMenu -import org.oxycblt.auxio.ui.sliceArticle import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -48,11 +47,7 @@ class ArtistListFragment : HomeListFragment() { } override fun getPopup(pos: Int) = - unlikelyToBeNull(homeModel.artists.value)[pos] - .resolvedName - .sliceArticle() - .first() - .uppercase() + unlikelyToBeNull(homeModel.artists.value)[pos].sortName?.run { first().uppercase() } override fun onItemClick(item: Item) { check(item is Music) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index d7a59454f..28a34211b 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -28,7 +28,6 @@ import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.newMenu -import org.oxycblt.auxio.ui.sliceArticle import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -48,11 +47,7 @@ class GenreListFragment : HomeListFragment() { } override fun getPopup(pos: Int) = - unlikelyToBeNull(homeModel.genres.value)[pos] - .resolvedName - .sliceArticle() - .first() - .uppercase() + unlikelyToBeNull(homeModel.genres.value)[pos].sortName?.run { first().uppercase() } override fun onItemClick(item: Item) { check(item is Music) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index d5c6da40f..c41f4d370 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -29,7 +29,6 @@ import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.newMenu -import org.oxycblt.auxio.ui.sliceArticle import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -48,7 +47,7 @@ class SongListFragment : HomeListFragment() { homeModel.songs.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) } } - override fun getPopup(pos: Int): String { + override fun getPopup(pos: Int): String? { val song = unlikelyToBeNull(homeModel.songs.value)[pos] // Change how we display the popup depending on the mode. @@ -56,16 +55,16 @@ class SongListFragment : HomeListFragment() { // based off the names of the parent objects and not the child objects. return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) { // Name -> Use name - is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase() + is Sort.ByName -> song.sortName.first().uppercase() // Artist -> Use Artist Name - is Sort.ByArtist -> song.album.artist.resolvedName.sliceArticle().first().uppercase() + is Sort.ByArtist -> song.album.artist.sortName?.run { first().uppercase() } // Album -> Use Album Name - is Sort.ByAlbum -> song.album.resolvedName.sliceArticle().first().uppercase() + is Sort.ByAlbum -> song.album.sortName.first().uppercase() // Year -> Use Full Year - is Sort.ByYear -> song.album.year?.toString() ?: getString(R.string.def_date) + is Sort.ByYear -> song.album.year?.toString() } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt index 9603fb428..0d20446e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt @@ -24,9 +24,6 @@ import androidx.recyclerview.widget.RecyclerView /** * A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu. * Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. - * - * TODO: Consider unifying the shared behavior between this and QueueDragCallback into a single - * class. */ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() { override fun getMovementFlags( diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 2ee85a563..602c9f802 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -18,22 +18,32 @@ package org.oxycblt.auxio.music import android.content.ContentUris +import android.content.Context import android.net.Uri import android.provider.MediaStore +import android.text.TextUtils.isDigitsOnly +import androidx.core.text.isDigitsOnly +import org.oxycblt.auxio.R import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.util.unlikelyToBeNull // --- MUSIC MODELS --- -/** [Item] variant that represents a music item. */ +/** + * [Item] variant that represents a music item. + */ sealed class Music : Item() { - /** The raw name of this item. */ - abstract val rawName: String + /** The raw name of this item. Null if unknown. */ + abstract val rawName: String? + + /** The name of this item used for sorting. Null if unknown. */ + abstract val sortName: String? + /** - * A name resolved from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" - * would become Unknown Artist, (124) would become its proper genre name, etc. + * Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would + * become Unknown Artist, (124) would become its proper genre name, etc. */ - abstract val resolvedName: String + abstract fun resolveName(context: Context): String } /** @@ -74,8 +84,10 @@ data class Song( return result } - override val resolvedName: String - get() = rawName + override val sortName: String + get() = rawName.withoutArticle + + override fun resolveName(context: Context) = rawName /** The URI for this song. */ val uri: Uri @@ -96,13 +108,19 @@ data class Song( val genre: Genre get() = unlikelyToBeNull(mGenre) - /** An album name resolved to this song in particular. */ - val resolvedAlbumName: String - get() = album.resolvedName + /** + * The raw artist name for this song in particular. First uses the artist tag, and then falls + * back to the album artist tag (i.e parent artist name). Null if name is unknown. + */ + val individualRawArtistName: String? + get() = internalMediaStoreArtistName ?: album.artist.rawName - /** An artist name resolved to this song in particular. */ - val resolvedArtistName: String - get() = internalMediaStoreArtistName ?: album.artist.resolvedName + /** + * Resolve the artist name for this song in particular. First uses the artist tag, and + * then falls back to the album artist tag (i.e parent artist name) + */ + fun resolveIndividualArtistName(context: Context) = + internalMediaStoreArtistName ?: album.artist.resolveName(context) /** Internal field. Do not use. */ val internalAlbumGroupingId: Long @@ -165,8 +183,10 @@ data class Album( return result } - override val resolvedName: String - get() = rawName + override val sortName: String + get() = rawName.withoutArticle + + override fun resolveName(context: Context) = rawName /** The formatted total duration of this album */ val totalDuration: String @@ -177,10 +197,6 @@ data class Album( val artist: Artist get() = unlikelyToBeNull(mArtist) - /** The artist name, resolved to this album in particular. */ - val resolvedArtistName: String - get() = artist.resolvedName - /** Internal field. Do not use. */ val internalArtistGroupingId: Long get() = internalGroupingArtistName.lowercase().hashCode().toLong() @@ -200,8 +216,7 @@ data class Album( * artist or artist field, not the individual performers of an artist. */ data class Artist( - override val rawName: String, - override val resolvedName: String, + override val rawName: String?, /** The albums of this artist. */ val albums: List ) : MusicParent() { @@ -211,27 +226,286 @@ data class Artist( } } - override val id = rawName.hashCode().toLong() + override val id: Long + get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong() + + override val sortName: String? + get() = rawName?.withoutArticle + + override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) /** The songs of this artist. */ val songs = albums.flatMap { it.songs } } /** The data object for a genre. */ -data class Genre( - override val rawName: String, - override val resolvedName: String, - val songs: List -) : MusicParent() { +data class Genre(override val rawName: String?, val songs: List) : MusicParent() { init { for (song in songs) { song.internalLinkGenre(this) } } - override val id = rawName.hashCode().toLong() + override val id: Long + get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong() + + override val sortName: String? + get() = rawName?.genreNameCompat + + override fun resolveName(context: Context) = + rawName?.genreNameCompat ?: context.getString(R.string.def_genre) /** The formatted total duration of this genre */ val totalDuration: String get() = songs.sumOf { it.seconds }.toDuration(false) } + +/** + * Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously + * anglo-centric, but its mostly for MediaStore compat and hopefully shouldn't run with other + * languages. + */ +private val String.withoutArticle: String + get() { + if (length > 5 && startsWith("the ", ignoreCase = true)) { + return slice(4..lastIndex) + } + + if (length > 4 && startsWith("an ", ignoreCase = true)) { + return slice(3..lastIndex) + } + + if (length > 3 && startsWith("a ", ignoreCase = true)) { + return slice(2..lastIndex) + } + + return this + } + +/** + * Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre constant + * map that Auxio uses. + */ +private val String.genreNameCompat: String + get() { + if (isDigitsOnly()) { + // ID3v1, just parse as an integer + return genreConstantTable.getOrNull(toInt()) ?: this + } + + if (startsWith('(') && endsWith(')')) { + // ID3v2.3/ID3v2.4, parse out the parentheses and get the integer + // Any genres formatted as "(CHARS)" will be ignored. + val genreInt = substring(1 until lastIndex).toIntOrNull() + if (genreInt != null) { + return genreConstantTable.getOrNull(genreInt) ?: this + } + } + + // Current name is fine. + return this + } + +/** + * A complete table of all the constant genre values for ID3(v2), including non-standard extensions. + */ +private val genreConstantTable = + arrayOf( + // ID3 Standard + "Blues", + "Classic Rock", + "Country", + "Dance", + "Disco", + "Funk", + "Grunge", + "Hip-Hop", + "Jazz", + "Metal", + "New Age", + "Oldies", + "Other", + "Pop", + "R&B", + "Rap", + "Reggae", + "Rock", + "Techno", + "Industrial", + "Alternative", + "Ska", + "Death Metal", + "Pranks", + "Soundtrack", + "Euro-Techno", + "Ambient", + "Trip-Hop", + "Vocal", + "Jazz+Funk", + "Fusion", + "Trance", + "Classical", + "Instrumental", + "Acid", + "House", + "Game", + "Sound Clip", + "Gospel", + "Noise", + "AlternRock", + "Bass", + "Soul", + "Punk", + "Space", + "Meditative", + "Instrumental Pop", + "Instrumental Rock", + "Ethnic", + "Gothic", + "Darkwave", + "Techno-Industrial", + "Electronic", + "Pop-Folk", + "Eurodance", + "Dream", + "Southern Rock", + "Comedy", + "Cult", + "Gangsta", + "Top 40", + "Christian Rap", + "Pop/Funk", + "Jungle", + "Native American", + "Cabaret", + "New Wave", + "Psychadelic", + "Rave", + "Showtunes", + "Trailer", + "Lo-Fi", + "Tribal", + "Acid Punk", + "Acid Jazz", + "Polka", + "Retro", + "Musical", + "Rock & Roll", + "Hard Rock", + + // Winamp extensions, more or less a de-facto standard + "Folk", + "Folk-Rock", + "National Folk", + "Swing", + "Fast Fusion", + "Bebob", + "Latin", + "Revival", + "Celtic", + "Bluegrass", + "Avantgarde", + "Gothic Rock", + "Progressive Rock", + "Psychedelic Rock", + "Symphonic Rock", + "Slow Rock", + "Big Band", + "Chorus", + "Easy Listening", + "Acoustic", + "Humour", + "Speech", + "Chanson", + "Opera", + "Chamber Music", + "Sonata", + "Symphony", + "Booty Bass", + "Primus", + "Porn Groove", + "Satire", + "Slow Jam", + "Club", + "Tango", + "Samba", + "Folklore", + "Ballad", + "Power Ballad", + "Rhythmic Soul", + "Freestyle", + "Duet", + "Punk Rock", + "Drum Solo", + "A capella", + "Euro-House", + "Dance Hall", + "Goa", + "Drum & Bass", + "Club-House", + "Hardcore", + "Terror", + "Indie", + "Britpop", + "Negerpunk", + "Polsk Punk", + "Beat", + "Christian Gangsta", + "Heavy Metal", + "Black Metal", + "Crossover", + "Contemporary Christian", + "Christian Rock", + "Merengue", + "Salsa", + "Thrash Metal", + "Anime", + "JPop", + "Synthpop", + + // Winamp 5.6+ extensions, also used by EasyTAG. + // I only include this because post-rock is a based genre and deserves a slot. + "Abstract", + "Art Rock", + "Baroque", + "Bhangra", + "Big Beat", + "Breakbeat", + "Chillout", + "Downtempo", + "Dub", + "EBM", + "Eclectic", + "Electro", + "Electroclash", + "Emo", + "Experimental", + "Garage", + "Global", + "IDM", + "Illbient", + "Industro-Goth", + "Jam Band", + "Krautrock", + "Leftfield", + "Lounge", + "Math Rock", + "New Romantic", + "Nu-Breakz", + "Post-Punk", + "Post-Rock", + "Psytrance", + "Shoegaze", + "Space Rock", + "Trop Rock", + "World Music", + "Neoclassical", + "Audiobook", + "Audio Theatre", + "Neue Deutsche Welle", + "Podcast", + "Indie Rock", + "G-Funk", + "Dubstep", + "Garage Rock", + "Psybient") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt index 23bb9faa2..33cd1c50b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt @@ -24,8 +24,6 @@ import android.net.Uri import android.provider.MediaStore import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull -import androidx.core.text.isDigitsOnly -import org.oxycblt.auxio.R import org.oxycblt.auxio.music.excluded.ExcludedDatabase import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.util.logD @@ -104,7 +102,7 @@ class MusicLoader { if (songs.isEmpty()) return null val albums = buildAlbums(songs) - val artists = buildArtists(context, albums) + val artists = buildArtists(albums) val genres = readGenres(context, songs) // Sanity check: Ensure that all songs are linked up to albums/artists/genres. @@ -316,21 +314,20 @@ class MusicLoader { * Group up albums into artists. This also requires a de-duplication step due to some edge cases * where [buildAlbums] could not detect duplicates. */ - private fun buildArtists(context: Context, albums: List): List { + private fun buildArtists(albums: List): List { val artists = mutableListOf() val albumsByArtist = albums.groupBy { it.internalArtistGroupingId } for (entry in albumsByArtist) { val templateAlbum = entry.value[0] - val artistName = templateAlbum.internalGroupingArtistName - val resolvedName = + val artistName = when (templateAlbum.internalGroupingArtistName) { - MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist) - else -> artistName + MediaStore.UNKNOWN_STRING -> null + else -> templateAlbum.internalGroupingArtistName } val artistAlbums = entry.value - artists.add(Artist(artistName, resolvedName, artistAlbums)) + artists.add(Artist(artistName, artistAlbums)) } logD("Successfully built ${artists.size} artists") @@ -361,21 +358,16 @@ class MusicLoader { // anyway, so we skip genres that have them. val id = cursor.getLong(idIndex) val name = cursor.getStringOrNull(nameIndex) ?: continue - val resolvedName = name.genreNameCompat ?: name val genreSongs = queryGenreSongs(context, id, songs) ?: continue - genres.add(Genre(name, resolvedName, genreSongs)) + genres.add(Genre(name, genreSongs)) } } val songsWithoutGenres = songs.filter { it.internalIsMissingGenre } if (songsWithoutGenres.isNotEmpty()) { // Songs that don't have a genre will be thrown into an unknown genre. - val unknownGenre = - Genre( - MediaStore.UNKNOWN_STRING, - context.getString(R.string.def_genre), - songsWithoutGenres) + val unknownGenre = Genre(null, songsWithoutGenres) genres.add(unknownGenre) } @@ -385,30 +377,6 @@ class MusicLoader { return genres } - /** - * Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre - * constant map that Auxio uses. - */ - private val String.genreNameCompat: String? - get() { - if (isDigitsOnly()) { - // ID3v1, just parse as an integer - return genreConstantTable.getOrNull(toInt()) - } - - if (startsWith('(') && endsWith(')')) { - // ID3v2.3/ID3v2.4, parse out the parentheses and get the integer - // Any genres formatted as "(CHARS)" will be ignored. - val genreInt = substring(1 until lastIndex).toIntOrNull() - if (genreInt != null) { - return genreConstantTable.getOrNull(genreInt) - } - } - - // Current name is fine. - return null - } - /** * Queries the genre songs for [genreId]. Some genres are insane and don't contain songs for * some reason, so if that's the case then this function will return null. @@ -446,210 +414,5 @@ class MusicLoader { */ @Suppress("InlinedApi") private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST - - /** - * A complete table of all the constant genre values for ID3(v2), including non-standard - * extensions. - */ - private val genreConstantTable = - arrayOf( - // ID3 Standard - "Blues", - "Classic Rock", - "Country", - "Dance", - "Disco", - "Funk", - "Grunge", - "Hip-Hop", - "Jazz", - "Metal", - "New Age", - "Oldies", - "Other", - "Pop", - "R&B", - "Rap", - "Reggae", - "Rock", - "Techno", - "Industrial", - "Alternative", - "Ska", - "Death Metal", - "Pranks", - "Soundtrack", - "Euro-Techno", - "Ambient", - "Trip-Hop", - "Vocal", - "Jazz+Funk", - "Fusion", - "Trance", - "Classical", - "Instrumental", - "Acid", - "House", - "Game", - "Sound Clip", - "Gospel", - "Noise", - "AlternRock", - "Bass", - "Soul", - "Punk", - "Space", - "Meditative", - "Instrumental Pop", - "Instrumental Rock", - "Ethnic", - "Gothic", - "Darkwave", - "Techno-Industrial", - "Electronic", - "Pop-Folk", - "Eurodance", - "Dream", - "Southern Rock", - "Comedy", - "Cult", - "Gangsta", - "Top 40", - "Christian Rap", - "Pop/Funk", - "Jungle", - "Native American", - "Cabaret", - "New Wave", - "Psychadelic", - "Rave", - "Showtunes", - "Trailer", - "Lo-Fi", - "Tribal", - "Acid Punk", - "Acid Jazz", - "Polka", - "Retro", - "Musical", - "Rock & Roll", - "Hard Rock", - - // Winamp extensions, more or less a de-facto standard - "Folk", - "Folk-Rock", - "National Folk", - "Swing", - "Fast Fusion", - "Bebob", - "Latin", - "Revival", - "Celtic", - "Bluegrass", - "Avantgarde", - "Gothic Rock", - "Progressive Rock", - "Psychedelic Rock", - "Symphonic Rock", - "Slow Rock", - "Big Band", - "Chorus", - "Easy Listening", - "Acoustic", - "Humour", - "Speech", - "Chanson", - "Opera", - "Chamber Music", - "Sonata", - "Symphony", - "Booty Bass", - "Primus", - "Porn Groove", - "Satire", - "Slow Jam", - "Club", - "Tango", - "Samba", - "Folklore", - "Ballad", - "Power Ballad", - "Rhythmic Soul", - "Freestyle", - "Duet", - "Punk Rock", - "Drum Solo", - "A capella", - "Euro-House", - "Dance Hall", - "Goa", - "Drum & Bass", - "Club-House", - "Hardcore", - "Terror", - "Indie", - "Britpop", - "Negerpunk", - "Polsk Punk", - "Beat", - "Christian Gangsta", - "Heavy Metal", - "Black Metal", - "Crossover", - "Contemporary Christian", - "Christian Rock", - "Merengue", - "Salsa", - "Thrash Metal", - "Anime", - "JPop", - "Synthpop", - - // Winamp 5.6+ extensions, also used by EasyTAG. - // I only include this because post-rock is a based genre and deserves a slot. - "Abstract", - "Art Rock", - "Baroque", - "Bhangra", - "Big Beat", - "Breakbeat", - "Chillout", - "Downtempo", - "Dub", - "EBM", - "Eclectic", - "Electro", - "Electroclash", - "Emo", - "Experimental", - "Garage", - "Global", - "IDM", - "Illbient", - "Industro-Goth", - "Jam Band", - "Krautrock", - "Leftfield", - "Lounge", - "Math Rock", - "New Romantic", - "Nu-Breakz", - "Post-Punk", - "Post-Rock", - "Psytrance", - "Shoegaze", - "Space Rock", - "Trop Rock", - "World Music", - "Neoclassical", - "Audiobook", - "Audio Theatre", - "Neue Deutsche Welle", - "Podcast", - "Indie Rock", - "G-Funk", - "Dubstep", - "Garage Rock", - "Psybient") } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt index 875ce90d5..a8eede553 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt @@ -22,9 +22,9 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import androidx.core.database.sqlite.transaction -import org.oxycblt.auxio.util.assertBackgroundThread import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.queryAll +import org.oxycblt.auxio.util.requireBackgroundThread /** * Database for storing excluded directories. Note that the paths stored here will not work with @@ -48,7 +48,7 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu /** Write a list of [paths] to the database. */ fun writePaths(paths: List) { - assertBackgroundThread() + requireBackgroundThread() writableDatabase.transaction { delete(TABLE_NAME, null, null) @@ -64,7 +64,7 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu /** Get the current list of paths from the database. */ fun readPaths(): List { - assertBackgroundThread() + requireBackgroundThread() val paths = mutableListOf() readableDatabase.queryAll(TABLE_NAME) { cursor -> diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index 52d7857c2..460d4c132 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -27,6 +27,7 @@ import com.google.android.material.color.MaterialColors import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -93,22 +94,28 @@ class PlaybackBarFragment : ViewBindingFragment() { // -- VIEWMODEL SETUP --- - playbackModel.song.observe(viewLifecycleOwner) { song -> - if (song != null) { - binding.playbackCover.bindAlbumCover(song) - binding.playbackSong.textSafe = song.resolvedName - binding.playbackInfo.textSafe = - getString(R.string.fmt_two, song.resolvedArtistName, song.resolvedAlbumName) - binding.playbackProgressBar.max = song.seconds.toInt() - } - } + playbackModel.song.observe(viewLifecycleOwner, ::updateSong) + playbackModel.isPlaying.observe(viewLifecycleOwner, ::updateIsPlaying) - playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying -> - binding.playbackPlayPause.isActivated = isPlaying - } + playbackModel.positionSeconds.observe(viewLifecycleOwner, ::updatePosition) + } - playbackModel.positionSeconds.observe(viewLifecycleOwner) { position -> - binding.playbackProgressBar.progress = position.toInt() + private fun updateSong(song: Song?) { + if (song != null) { + val context = requireContext() + val binding = requireBinding() + binding.playbackCover.bindAlbumCover(song) + binding.playbackSong.textSafe = song.resolveName(context) + binding.playbackInfo.textSafe = song.resolveIndividualArtistName(context) + binding.playbackProgressBar.max = song.seconds.toInt() } } + + private fun updateIsPlaying(isPlaying: Boolean) { + requireBinding().playbackPlayPause.isActivated = isPlaying + } + + private fun updatePosition(position: Long) { + requireBinding().playbackProgressBar.progress = position.toInt() + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 8cef08fc0..70b573391 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -169,10 +169,11 @@ class PlaybackPanelFragment : if (song == null) return val binding = requireBinding() + val context = requireContext() binding.playbackCover.bindAlbumCover(song) - binding.playbackSong.textSafe = song.resolvedName - binding.playbackArtist.textSafe = song.resolvedArtistName - binding.playbackAlbum.textSafe = song.resolvedAlbumName + binding.playbackSong.textSafe = song.resolveName(context) + binding.playbackArtist.textSafe = song.resolveIndividualArtistName(context) + binding.playbackAlbum.textSafe = song.album.resolveName(context) // Normally if a song had a duration val seconds = song.seconds @@ -185,7 +186,7 @@ class PlaybackPanelFragment : private fun updateParent(parent: MusicParent?) { requireBinding().playbackToolbar.subtitle = - parent?.resolvedName ?: getString(R.string.lbl_all_songs) + parent?.resolveName(requireContext()) ?: getString(R.string.lbl_all_songs) } private fun updatePosition(position: Long) { 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 2ffaca04a..dc5d92498 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 @@ -35,6 +35,7 @@ import org.oxycblt.auxio.ui.BackingData import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.SongViewHolder +import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.disableDropShadowCompat import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.stateList @@ -71,8 +72,8 @@ private constructor( @SuppressLint("ClickableViewAccessibility") override fun bind(item: Song, listener: QueueItemListener) { binding.songAlbumCover.bindAlbumCover(item) - binding.songName.textSafe = item.resolvedName - binding.songInfo.textSafe = item.resolvedArtistName + binding.songName.textSafe = item.resolveName(binding.context) + binding.songInfo.textSafe = item.resolveIndividualArtistName(binding.context) binding.background.isInvisible = true diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt index 12116f21c..600dbe3bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt @@ -26,9 +26,9 @@ import androidx.core.database.sqlite.transaction import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.assertBackgroundThread import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.queryAll +import org.oxycblt.auxio.util.requireBackgroundThread /** * A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists. @@ -106,7 +106,7 @@ class PlaybackStateDatabase(context: Context) : * @return The stored [SavedState], null if there isn't one. */ fun readState(musicStore: MusicStore): SavedState? { - assertBackgroundThread() + requireBackgroundThread() var state: SavedState? = null @@ -157,7 +157,7 @@ class PlaybackStateDatabase(context: Context) : /** Clear the previously written [SavedState] and write a new one. */ fun writeState(state: SavedState) { - assertBackgroundThread() + requireBackgroundThread() writableDatabase.transaction { delete(TABLE_NAME_STATE, null, null) @@ -187,7 +187,7 @@ class PlaybackStateDatabase(context: Context) : * @param musicStore Required to transform database songs into actual song instances */ fun readQueue(musicStore: MusicStore): MutableList { - assertBackgroundThread() + requireBackgroundThread() val queue = mutableListOf() @@ -210,7 +210,7 @@ class PlaybackStateDatabase(context: Context) : /** Write a queue to the database. */ fun writeQueue(queue: MutableList) { - assertBackgroundThread() + requireBackgroundThread() val database = writableDatabase database.transaction { delete(TABLE_NAME_QUEUE, null, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt index a3955c84b..5f30e90cd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt @@ -72,13 +72,13 @@ private constructor(private val context: Context, mediaToken: MediaSessionCompat * @param onDone What to do when the loading of the album art is finished */ fun setMetadata(song: Song, onDone: () -> Unit) { - setContentTitle(song.resolvedName) - setContentText(song.resolvedArtistName) + setContentTitle(song.resolveName(context)) + setContentText(song.resolveIndividualArtistName(context)) // On older versions of android [API <24], show the song's album on the subtext instead of // the current mode, as that makes more sense for the old style of media notifications. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - setSubText(song.resolvedAlbumName) + setSubText(song.resolveName(context)) } // loadBitmap() is concurrent, so only call back to the object calling this function when @@ -109,7 +109,7 @@ private constructor(private val context: Context, mediaToken: MediaSessionCompat if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return // A blank parent always means that the mode is ALL_SONGS - setSubText(parent?.resolvedName ?: context.getString(R.string.lbl_all_songs)) + setSubText(parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) } // --- NOTIFICATION ACTION BUILDERS --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt index 4953d5aea..a08cad080 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt @@ -114,17 +114,18 @@ class PlaybackSessionConnector( return } - val artistName = song.resolvedArtistName + val artistName = song.resolveIndividualArtistName(context) val builder = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.resolvedName) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.resolvedName) + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.resolveName(context)) + .putString( + MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.resolveName(context)) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName) .putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artistName) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.resolvedAlbumName) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context)) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration) // Load the cover asynchronously. This is the entire reason I don't use a plain diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index df7c9fb0e..2e114da2b 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -35,6 +35,8 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.Header @@ -56,6 +58,7 @@ class SearchFragment : ViewBindingFragment(), MenuItemLis private val searchModel: SearchViewModel by viewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() private val searchAdapter = SearchAdapter(this) private var imm: InputMethodManager? = null @@ -74,7 +77,7 @@ class SearchFragment : ViewBindingFragment(), MenuItemLis setOnMenuItemClickListener { item -> if (item.itemId != R.id.submenu_filtering) { - searchModel.updateFilterModeWithId(item.itemId) + searchModel.updateFilterModeWithId(context, item.itemId) item.isChecked = true true } else { @@ -86,7 +89,7 @@ class SearchFragment : ViewBindingFragment(), MenuItemLis binding.searchEditText.apply { addTextChangedListener { text -> // Run the search with the updated text as the query - searchModel.search(text?.toString() ?: "") + searchModel.search(context, text?.toString()) } if (!launchedKeyboard) { @@ -109,6 +112,7 @@ class SearchFragment : ViewBindingFragment(), MenuItemLis searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults) navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation) + musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse) } override fun onResume() { @@ -164,6 +168,12 @@ class SearchFragment : ViewBindingFragment(), MenuItemLis requireImm().hide() } + private fun handleLoaderResponse(response: MusicStore.Response?) { + if (response is MusicStore.Response.Ok) { + searchModel.refresh(requireContext()) + } + } + private fun requireImm(): InputMethodManager { requireAttached() val instance = imm diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index c220b7b4a..e9205aece 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -17,6 +17,7 @@ package org.oxycblt.auxio.search +import android.content.Context import androidx.annotation.IdRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -42,7 +43,7 @@ class SearchViewModel : ViewModel() { private val mSearchResults = MutableLiveData(listOf()) private var mIsNavigating = false private var mFilterMode: DisplayMode? = null - private var mLastQuery = "" + private var mLastQuery: String? = null /** Current search results from the last [search] call. */ val searchResults: LiveData> @@ -54,21 +55,16 @@ class SearchViewModel : ViewModel() { init { mFilterMode = settingsManager.searchFilterMode - - viewModelScope.launch { - MusicStore.awaitInstance() - search(mLastQuery) - } } /** * Use [query] to perform a search of the music library. Will push results to [searchResults]. */ - fun search(query: String) { + fun search(context: Context, query: String?) { val musicStore = MusicStore.maybeGetInstance() mLastQuery = query - if (query.isEmpty() || musicStore == null) { + if (query.isNullOrEmpty() || musicStore == null) { logD("No music/query, ignoring search") mSearchResults.value = listOf() return @@ -84,28 +80,28 @@ class SearchViewModel : ViewModel() { // Note: a filter mode of null means to not filter at all. if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) { - musicStore.artists.filterByOrNull(query)?.let { artists -> + musicStore.artists.filterByOrNull(context, query)?.let { artists -> results.add(Header(-1, R.string.lbl_artists)) results.addAll(sort.artists(artists)) } } if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) { - musicStore.albums.filterByOrNull(query)?.let { albums -> + musicStore.albums.filterByOrNull(context, query)?.let { albums -> results.add(Header(-2, R.string.lbl_albums)) results.addAll(sort.albums(albums)) } } if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) { - musicStore.genres.filterByOrNull(query)?.let { genres -> + musicStore.genres.filterByOrNull(context, query)?.let { genres -> results.add(Header(-3, R.string.lbl_genres)) results.addAll(sort.genres(genres)) } } if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) { - musicStore.songs.filterByOrNull(query)?.let { songs -> + musicStore.songs.filterByOrNull(context, query)?.let { songs -> results.add(Header(-4, R.string.lbl_songs)) results.addAll(sort.songs(songs)) } @@ -115,10 +111,15 @@ class SearchViewModel : ViewModel() { } } + /** Re-search the library using the last query. Will push results to [searchResults]. */ + fun refresh(context: Context) { + search(context, mLastQuery) + } + /** * Update the current filter mode with a menu [id]. New value will be pushed to [filterMode]. */ - fun updateFilterModeWithId(@IdRes id: Int) { + fun updateFilterModeWithId(context: Context, @IdRes id: Int) { mFilterMode = when (id) { R.id.option_filter_songs -> DisplayMode.SHOW_SONGS @@ -132,33 +133,33 @@ class SearchViewModel : ViewModel() { settingsManager.searchFilterMode = mFilterMode - search(mLastQuery) + refresh(context) } /** * Shortcut that will run a ignoreCase filter on a list and only return a value if the resulting * list is empty. */ - private fun List.filterByOrNull(value: String): List? { + private fun List.filterByOrNull(context: Context, value: String): List? { val filtered = filter { // First see if the normal item name will work. If that fails, try the "normalized" // [e.g all accented/unicode chars become latin chars] instead. Hopefully this // shouldn't break other language's search functionality. - it.resolvedName.contains(value, ignoreCase = true) || - it.resolvedName.normalized().contains(value, ignoreCase = true) + it.resolveNameNormalized(context).contains(value, ignoreCase = true) || + it.resolveNameNormalized(context).contains(value, ignoreCase = true) } return filtered.ifEmpty { null } } - private fun String.normalized(): String { + private fun Music.resolveNameNormalized(context: Context): String { // This method normalizes strings so that songs with accented characters will show // up in search even if the actual character was not inputted. // https://stackoverflow.com/a/32030586/14143986 // Normalize with NFKD [Meaning that symbols with identical meanings will be turned into // their letter variants]. - val norm = Normalizer.normalize(this, Normalizer.Form.NFKD) + val norm = Normalizer.normalize(resolveName(context), Normalizer.Form.NFKD) // Normalizer doesn't exactly finish the job though. We have to rebuild all the codepoints // in the string and remove the hidden characters that were added by Normalizer. diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt index d8a0288f3..6c71f6e20 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt @@ -22,9 +22,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import org.oxycblt.auxio.music.Music -/** - * A ViewModel that handles complicated navigation situations. - */ +/** A ViewModel that handles complicated navigation situations. */ class NavigationViewModel : ViewModel() { private val mMainNavigationAction = MutableLiveData() /** Flag for main fragment navigation. Intended for MainFragment use only. */ @@ -36,9 +34,7 @@ class NavigationViewModel : ViewModel() { val exploreNavigationItem: LiveData get() = mExploreNavigationItem - /** - * Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. - */ + /** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */ fun mainNavigateTo(action: MainNavigationAction) { if (mMainNavigationAction.value != null) return mMainNavigationAction.value = action diff --git a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt index 6282c9412..d11e4325a 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt @@ -42,6 +42,8 @@ import org.oxycblt.auxio.util.logW * representing whether this sort is ascending or descending. * * @author OxygenCobalt + * + * TODO: Make comparators static instances */ sealed class Sort(open val isAscending: Boolean) { protected abstract val sortIntCode: Int @@ -240,9 +242,16 @@ sealed class Sort(open val isAscending: Boolean) { class NameComparator : Comparator { override fun compare(a: T, b: T): Int { - return a.resolvedName - .sliceArticle() - .compareTo(b.resolvedName.sliceArticle(), ignoreCase = true) + val aSortName = a.sortName + val bSortName = b.sortName + return when { + aSortName != null && bSortName != null -> + aSortName.compareTo(bSortName, ignoreCase = true) + aSortName == null && bSortName != null -> -1 // a < b + aSortName == null && bSortName == null -> 0 // a = b + aSortName != null && bSortName == null -> 1 // a < b + else -> error("Unreachable") + } } } @@ -300,24 +309,3 @@ sealed class Sort(open val isAscending: Boolean) { } } } - -/** - * Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously - * anglo-centric, but its mostly for MediaStore compat and hopefully shouldn't run with other - * languages. - */ -fun String.sliceArticle(): String { - if (length > 5 && startsWith("the ", ignoreCase = true)) { - return slice(4..lastIndex) - } - - if (length > 4 && startsWith("an ", ignoreCase = true)) { - return slice(3..lastIndex) - } - - if (length > 3 && startsWith("a ", ignoreCase = true)) { - return slice(2..lastIndex) - } - - return this -} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt index 919af1367..c93272e33 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewHolders.kt @@ -40,8 +40,8 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : BindingViewHolder(binding.root) { override fun bind(item: Song, listener: MenuItemListener) { binding.songAlbumCover.bindAlbumCover(item) - binding.songName.textSafe = item.resolvedName - binding.songInfo.textSafe = item.resolvedArtistName + binding.songName.textSafe = item.resolveName(binding.context) + binding.songInfo.textSafe = item.resolveIndividualArtistName(binding.context) binding.root.apply { setOnClickListener { listener.onItemClick(item) } setOnLongClickListener { view -> @@ -64,8 +64,8 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Song, newItem: Song) = - oldItem.resolvedName == newItem.resolvedName && - oldItem.resolvedArtistName == oldItem.resolvedArtistName + oldItem.rawName == newItem.rawName && + oldItem.individualRawArtistName == oldItem.individualRawArtistName } } } @@ -78,8 +78,8 @@ private constructor( override fun bind(item: Album, listener: MenuItemListener) { binding.parentImage.bindAlbumCover(item) - binding.parentName.textSafe = item.resolvedName - binding.parentInfo.textSafe = item.resolvedArtistName + binding.parentName.textSafe = item.resolveName(binding.context) + binding.parentInfo.textSafe = item.artist.resolveName(binding.context) binding.root.apply { setOnClickListener { listener.onItemClick(item) } setOnLongClickListener { view -> @@ -102,8 +102,8 @@ private constructor( val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Album, newItem: Album) = - oldItem.resolvedName == newItem.resolvedName && - oldItem.resolvedArtistName == newItem.resolvedArtistName + oldItem.rawName == newItem.rawName && + oldItem.artist.rawName == newItem.artist.rawName } } } @@ -114,7 +114,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin override fun bind(item: Artist, listener: MenuItemListener) { binding.parentImage.bindArtistImage(item) - binding.parentName.textSafe = item.resolvedName + binding.parentName.textSafe = item.resolveName(binding.context) binding.parentInfo.textSafe = binding.context.getString( R.string.fmt_two, @@ -142,7 +142,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Artist, newItem: Artist) = - oldItem.resolvedName == newItem.resolvedName && + oldItem.rawName == newItem.rawName && oldItem.albums.size == newItem.albums.size && newItem.songs.size == newItem.songs.size } @@ -157,7 +157,7 @@ private constructor( override fun bind(item: Genre, listener: MenuItemListener) { binding.parentImage.bindGenreImage(item) - binding.parentName.textSafe = item.resolvedName + binding.parentName.textSafe = item.resolveName(binding.context) binding.parentInfo.textSafe = binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) binding.root.apply { @@ -182,8 +182,7 @@ private constructor( val DIFFER = object : SimpleItemCallback() { override fun areItemsTheSame(oldItem: Genre, newItem: Genre): Boolean = - oldItem.resolvedName == newItem.resolvedName && - oldItem.songs.size == newItem.songs.size + oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size } } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index 6d3ae3ee3..5da25bd1b 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -49,6 +49,10 @@ fun View.disableDropShadowCompat() { } } +/** + * Determines if the point given by [x] and [y] falls within this view. + * @param minTouchTargetSize The minimum touch size, independent of the view's size (Optional) + */ fun View.isUnder(x: Float, y: Float, minTouchTargetSize: Int = 0): Boolean { return isUnderImpl(x, left, right, (parent as View).width, minTouchTargetSize) && isUnderImpl(y, top, bottom, (parent as View).height, minTouchTargetSize) @@ -85,15 +89,22 @@ private fun isUnderImpl( return position >= touchTargetStart && position < touchTargetEnd } +/** Returns if this view is RTL in a compatible manner. */ val View.isRtl: Boolean get() = layoutDirection == View.LAYOUT_DIRECTION_RTL +/** Returns if this drawable is RTL in a compatible manner.] */ val Drawable.isRtl: Boolean get() = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL +/** Shortcut to get a context from a ViewBinding */ val ViewBinding.context: Context get() = root.context +/** + * A variation of [TextView.setText] that automatically relayouts the view when updated. Helps with + * getting ellipsize functionality to work. + */ var TextView.textSafe: CharSequence get() = text set(value) { @@ -126,13 +137,6 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) { } } -@Suppress("UNCHECKED_CAST") -fun RecyclerView.getViewHolderAt(pos: Int): RecyclerView.ViewHolder? { - return layoutManager?.run { - findViewByPosition(pos)?.let { child -> getChildViewHolder(child) } - } -} - /** Returns whether a recyclerview can scroll. */ fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt index 2727cb9cb..cdf805eb2 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt @@ -38,18 +38,18 @@ fun Any.logD(obj: Any) { fun Any.logD(msg: String) { if (BuildConfig.DEBUG) { basedCopyleftNotice() - Log.d(getName(), msg) + Log.d(name, msg) } } /** Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects */ fun Any.logW(msg: String) { - Log.w(getName(), msg) + Log.w(name, msg) } /** Shortcut method for logging [msg] as an error to the console. Handles anonymous objects */ fun Any.logE(msg: String) { - Log.e(getName(), msg) + Log.e(name, msg) } /** @@ -68,7 +68,8 @@ fun Throwable.logTraceOrThrow() { * Get a non-nullable name, used so that logs will always show up by Auxio * @return The name of the object, otherwise "Anonymous Object" */ -private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "Anonymous Object"}" +private val Any.name: String + get() = "Auxio.${this::class.simpleName ?: "Anonymous Object"}" /** * I know that this will not stop you, but consider what you are doing with your life, plagiarizers. diff --git a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt index ff2e37bf1..281f2fcca 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt @@ -22,7 +22,7 @@ import androidx.core.math.MathUtils import org.oxycblt.auxio.BuildConfig /** Assert that we are on a background thread. */ -fun assertBackgroundThread() { +fun requireBackgroundThread() { check(Looper.myLooper() != Looper.getMainLooper()) { "This operation must be ran on a background thread" } @@ -40,4 +40,5 @@ fun unlikelyToBeNull(value: T?): T { } } +/** Shortcut to clamp an integer between [min] and [max] */ fun Int.clamp(min: Int, max: Int): Int = MathUtils.clamp(this, min, max) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt index 094e2f4ce..9dbcc645f 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt @@ -90,8 +90,8 @@ private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews { private fun RemoteViews.applyMeta(context: Context, state: WidgetState): RemoteViews { applyCover(context, state) - setTextViewText(R.id.widget_song, state.song.resolvedName) - setTextViewText(R.id.widget_artist, state.song.resolvedArtistName) + setTextViewText(R.id.widget_song, state.song.resolveName(context)) + setTextViewText(R.id.widget_artist, state.song.resolveIndividualArtistName(context)) return this } @@ -101,7 +101,7 @@ private fun RemoteViews.applyCover(context: Context, state: WidgetState): Remote setImageViewBitmap(R.id.widget_cover, state.albumArt) setContentDescription( R.id.widget_cover, - context.getString(R.string.desc_album_cover, state.song.resolvedAlbumName)) + context.getString(R.string.desc_album_cover, state.song.album.resolveName(context))) } else { setImageViewResource(R.id.widget_cover, R.drawable.ic_widget_album) setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover)) diff --git a/info/ARCHITECTURE.md b/info/ARCHITECTURE.md index 64383fda7..b22772293 100644 --- a/info/ARCHITECTURE.md +++ b/info/ARCHITECTURE.md @@ -88,8 +88,7 @@ It has the following implementations: - `Music` is a `Item` that represents music. It adds a `name` field that represents the raw name of the music (from `MediaStore`). - `MusicParent` is a type of `Music` that contains children. It adds a `resolveName` field that converts the raw `MediaStore` name to a name that can be used in UIs. -- `Header` and `ActionHeader` are UI data objects that represent a header item. `Header` corresponds to a simple header with no action, -while `ActionHeader` corresponds to an action with a dedicated icon, such as with sorting. +- `Header` corresponds to a simple header. The Detail UIs have a derivative called `SortHeader` that also adds a sorting button. Other data types represent a specific UI configuration or state: - Sealed classes like `Sort` contain data with them that can be modified. @@ -101,11 +100,11 @@ Attempting to use it as a `MediaStore` ID will result in errors. - Any field or method beginning with `internal` is off-limits. These fields are meant for use within `MusicLoader` and generally provide poor UX to the user. The only reason they are public is to make the loading process not have to rely on separate "Raw" objects. -- Generally, `rawName` is used when doing internal work, such as saving music data, while `resolvedName` is used when displaying music data to the user. - - For `Song` instances in particular, prefer `resolvedAlbumName` and `resolvedArtistName` over `album.resolvedName` and `album.artist.resolvedName`, - as these resolve the name in context of the song. - - For `Album` instances in particular, prefer `resolvedArtistName` over `artist.resolvedName`, which don't actually do anything but add consistency - to the `Song` function +- `rawName` is used when doing internal work, such as saving music data or diffing items +- `sortName` is used in the fast scroller indicators and sorting. Avoid it wherever else. +- `resolveName()` should be used when displaying any kind of music data to the user. +- For songs, `individualArtistRawName` and `resolveIndividualArtistName` should always be used when displaying the artist of +a song, as it will always show collaborator information first before deatiling to the album artist. #### Music Access All music on a system is asynchronously loaded into the shared object `MusicStore`. Because of this, **`MusicStore` may not be available at all times**.