music: rework name heirarchy

Once again rework the naming system for music, this time with it being
much easier to localize (hopefully).
This commit is contained in:
OxygenCobalt 2022-04-03 12:47:37 -06:00
parent ab194c14c2
commit 3a19d822ce
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
36 changed files with 534 additions and 506 deletions

View file

@ -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

View file

@ -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()

View file

@ -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)
}

View file

@ -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

View file

@ -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 <T : Music> StyledImageView.load(music: T?, @DrawableRes error: Int, @StringRes desc: Int) {
contentDescription = context.getString(desc, music?.resolvedName)
fun <T : Music> 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 <T : Music> 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

View file

@ -64,7 +64,7 @@ abstract class DetailFragment : ViewBindingFragment<FragmentDetailBinding>() {
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<FragmentDetailBinding>() {
) {
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)

View file

@ -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<Album>() {
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<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName &&
oldItem.duration == newItem.duration
oldItem.rawName == newItem.rawName && oldItem.duration == newItem.duration
}
}
}

View file

@ -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<Album, MenuItemListener>(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<Album>() {
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<Song, MenuItemListener>(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<Song>() {
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
}
}
}

View file

@ -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<L : DetailAdapter.Listener>(
// 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)

View file

@ -114,7 +114,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
BindingViewHolder<Genre, DetailAdapter.Listener>(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<Genre>() {
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<Song, MenuItemListener>(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 ->

View file

@ -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 {

View file

@ -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) {

View file

@ -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<Album>() {
// 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

View file

@ -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<Artist>() {
}
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)

View file

@ -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<Genre>() {
}
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)

View file

@ -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<Song>() {
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<Song>() {
// 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()
}
}

View file

@ -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(

View file

@ -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<Album>
) : 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<Song>
) : MusicParent() {
data class Genre(override val rawName: String?, val songs: List<Song>) : 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")

View file

@ -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<Album>): List<Artist> {
private fun buildArtists(albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
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")
}
}

View file

@ -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<String>) {
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<String> {
assertBackgroundThread()
requireBackgroundThread()
val paths = mutableListOf<String>()
readableDatabase.queryAll(TABLE_NAME) { cursor ->

View file

@ -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<FragmentPlaybackBarBinding>() {
// -- 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()
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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<Song> {
assertBackgroundThread()
requireBackgroundThread()
val queue = mutableListOf<Song>()
@ -210,7 +210,7 @@ class PlaybackStateDatabase(context: Context) :
/** Write a queue to the database. */
fun writeQueue(queue: MutableList<Song>) {
assertBackgroundThread()
requireBackgroundThread()
val database = writableDatabase
database.transaction { delete(TABLE_NAME_QUEUE, null, null) }

View file

@ -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 ---

View file

@ -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

View file

@ -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<FragmentSearchBinding>(), 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<FragmentSearchBinding>(), 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<FragmentSearchBinding>(), 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<FragmentSearchBinding>(), 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<FragmentSearchBinding>(), 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

View file

@ -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<Item>())
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<List<Item>>
@ -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 <T : Music> List<T>.filterByOrNull(value: String): List<T>? {
private fun <T : Music> List<T>.filterByOrNull(context: Context, value: String): List<T>? {
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.

View file

@ -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<MainNavigationAction?>()
/** Flag for main fragment navigation. Intended for MainFragment use only. */
@ -36,9 +34,7 @@ class NavigationViewModel : ViewModel() {
val exploreNavigationItem: LiveData<Music?>
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

View file

@ -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<T : Music> : Comparator<T> {
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
}

View file

@ -40,8 +40,8 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
BindingViewHolder<Song, MenuItemListener>(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<Song>() {
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<Album>() {
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<Artist>() {
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<Genre>() {
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
}
}
}

View file

@ -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

View file

@ -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.

View file

@ -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 <T> 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)

View file

@ -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))

View file

@ -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**.