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 - Made the layout of album songs more similar to other songs
#### Dev/Meta #### Dev/Meta
- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese ] - Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese]
- Switched to spotless and ktfmt instead of ktlint - Switched to spotless and ktfmt instead of ktlint
- Migrated constants to centralized table - Migrated constants to centralized table
- Introduced new RecyclerView framework - Introduced new RecyclerView framework
- Use native ExoPlayer AudioFocus implementation - Use native ExoPlayer AudioFocus implementation
- Make ReplayGain functionality use AudioProcessor instead of volume - Make ReplayGain functionality use AudioProcessor instead of volume
- Removed databinding [Greatly reduces compile times] - Removed databinding [Greatly reduces compile times]
- A bunch of internal view implementation improvements - An uncountable amount of internal codebase improvements
## v2.2.2 ## v2.2.2
#### What's New #### What's New

View file

@ -44,7 +44,9 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* *
* TODO: Rework menus [perhaps add multi-select] * 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() { class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels() 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.ui.MonoAdapter
import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getColorSafe import org.oxycblt.auxio.util.getColorSafe
import org.oxycblt.auxio.util.getViewHolderAt
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
@ -55,7 +54,8 @@ class AccentAdapter(listener: Listener) :
if (accent == selectedAccent) return if (accent == selectedAccent) return
selectedAccent = accent selectedAccent = accent
selectedViewHolder?.setSelected(false) selectedViewHolder?.setSelected(false)
selectedViewHolder = recycler.getViewHolderAt(accent.index) as AccentViewHolder? selectedViewHolder =
recycler.findViewHolderForAdapterPosition(accent.index) as AccentViewHolder?
selectedViewHolder?.setSelected(true) selectedViewHolder?.setSelected(true)
} }

View file

@ -51,7 +51,9 @@ import org.oxycblt.auxio.util.logW
/** /**
* The base implementation for all image fetchers in Auxio. * The base implementation for all image fetchers in Auxio.
* @author OxygenCobalt TODO: Artist images * @author OxygenCobalt
*
* TODO: Artist images
*/ */
abstract class BaseFetcher : Fetcher { abstract class BaseFetcher : Fetcher {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
@ -86,7 +88,7 @@ abstract class BaseFetcher : Fetcher {
// for a manual parser. // for a manual parser.
// However, Samsung seems to cripple this class as to force people to use their ad-infested // 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 // 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) val result = fetchAospMetadataCovers(context, album)
if (result != null) { if (result != null) {
return result return result

View file

@ -119,25 +119,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// eventually // eventually
/** Bind the album cover for a [song]. */ /** 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) load(song, R.drawable.ic_song, R.string.desc_album_cover)
/** Bind the album cover for an [album]. */ /** 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) load(album, R.drawable.ic_album, R.string.desc_album_cover)
/** Bind the image for an [artist] */ /** 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) load(artist, R.drawable.ic_artist, R.string.desc_artist_image)
/** Bind the image for a [genre] */ /** 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) load(genre, R.drawable.ic_genre, R.string.desc_genre_image)
fun <T : Music> StyledImageView.load(music: T?, @DrawableRes error: Int, @StringRes desc: Int) { fun <T : Music> StyledImageView.load(music: T, @DrawableRes error: Int, @StringRes desc: Int) {
contentDescription = context.getString(desc, music?.resolvedName) contentDescription = context.getString(desc, music.resolveName(context))
dispose() dispose()
scaleType = ImageView.ScaleType.FIT_CENTER
load(music) { load(music) {
error(error) error(error)
transformations(SquareFrameTransform.INSTANCE) transformations(SquareFrameTransform.INSTANCE)
@ -145,7 +144,7 @@ fun <T : Music> StyledImageView.load(music: T?, @DrawableRes error: Int, @String
onSuccess = { _, _ -> onSuccess = { _, _ ->
// Using the matrix scale type will shrink the cover images, so set it back to // Using the matrix scale type will shrink the cover images, so set it back to
// the default scale type. // the default scale type.
scaleType = ImageView.ScaleType.CENTER scaleType = ImageView.ScaleType.FIT_CENTER
}, },
onError = { _, _ -> onError = { _, _ ->
// Error icons need to be scaled correctly, so set it to the custom matrix // 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 onMenuClick: ((itemId: Int) -> Boolean)? = null
) { ) {
requireBinding().detailToolbar.apply { requireBinding().detailToolbar.apply {
title = data.resolvedName title = data.resolveName(context)
if (menuId != -1) { if (menuId != -1) {
inflateMenu(menuId) inflateMenu(menuId)
@ -93,9 +93,6 @@ abstract class DetailFragment : ViewBindingFragment<FragmentDetailBinding>() {
) { ) {
logD("Launching menu") logD("Launching menu")
// Scrolling breaks the menus, so we stop any momentum currently going on.
requireBinding().detailRecycler.stopScroll()
PopupMenu(anchor.context, anchor).apply { PopupMenu(anchor.context, anchor).apply {
inflate(R.menu.menu_detail_sort) 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.Item
import org.oxycblt.auxio.ui.MenuItemListener import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.ui.SimpleItemCallback import org.oxycblt.auxio.ui.SimpleItemCallback
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPluralSafe import org.oxycblt.auxio.util.getPluralSafe
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe 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) { override fun bind(item: Album, listener: AlbumDetailAdapter.Listener) {
binding.detailCover.bindAlbumCover(item) binding.detailCover.bindAlbumCover(item)
binding.detailName.textSafe = item.resolvedName binding.detailName.textSafe = item.resolveName(binding.context)
binding.detailSubhead.apply { binding.detailSubhead.apply {
textSafe = item.resolvedArtistName textSafe = item.artist.resolveName(context)
setOnClickListener { listener.onNavigateToArtist() } setOnClickListener { listener.onNavigateToArtist() }
} }
@ -152,8 +153,8 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
val DIFFER = val DIFFER =
object : SimpleItemCallback<Album>() { object : SimpleItemCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) = override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName &&
oldItem.resolvedArtistName == newItem.resolvedArtistName && oldItem.artist.rawName == newItem.artist.rawName &&
oldItem.year == newItem.year && oldItem.year == newItem.year &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.totalDuration == newItem.totalDuration oldItem.totalDuration == newItem.totalDuration
@ -183,7 +184,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
binding.songTrackBg.imageAlpha = 255 binding.songTrackBg.imageAlpha = 255
} }
binding.songName.textSafe = item.resolvedName binding.songName.textSafe = item.resolveName(binding.context)
binding.songDuration.textSafe = item.seconds.toDuration(false) binding.songDuration.textSafe = item.seconds.toDuration(false)
binding.root.apply { binding.root.apply {
@ -214,8 +215,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
val DIFFER = val DIFFER =
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) = override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName && oldItem.duration == newItem.duration
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) { override fun bind(item: Artist, listener: DetailAdapter.Listener) {
binding.detailCover.bindArtistImage(item) 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 // Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre. // the most "Prominent" genre.
binding.detailSubhead.textSafe = 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.context.getString(R.string.def_genre)
binding.detailInfo.textSafe = binding.detailInfo.textSafe =
@ -178,7 +182,7 @@ private constructor(
) : BindingViewHolder<Album, MenuItemListener>(binding.root), Highlightable { ) : BindingViewHolder<Album, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Album, listener: MenuItemListener) { override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bindAlbumCover(item) binding.parentImage.bindAlbumCover(item)
binding.parentName.textSafe = item.resolvedName binding.parentName.textSafe = item.resolveName(binding.context)
binding.parentInfo.textSafe = binding.parentInfo.textSafe =
if (item.year != null) { if (item.year != null) {
binding.context.getString(R.string.fmt_number, item.year) binding.context.getString(R.string.fmt_number, item.year)
@ -212,7 +216,7 @@ private constructor(
val DIFFER = val DIFFER =
object : SimpleItemCallback<Album>() { object : SimpleItemCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: 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 { ) : BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Song, listener: MenuItemListener) { override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(item) binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName binding.songName.textSafe = item.resolveName(binding.context)
binding.songInfo.textSafe = item.resolvedAlbumName binding.songInfo.textSafe = item.album.resolveName(binding.context)
binding.root.apply { binding.root.apply {
setOnClickListener { listener.onItemClick(item) } setOnClickListener { listener.onItemClick(item) }
@ -252,8 +256,8 @@ private constructor(
val DIFFER = val DIFFER =
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) = override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName &&
oldItem.resolvedAlbumName == newItem.resolvedAlbumName 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.NewHeaderViewHolder
import org.oxycblt.auxio.ui.SimpleItemCallback import org.oxycblt.auxio.ui.SimpleItemCallback
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getViewHolderAt
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.textSafe 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. // 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 // If the ViewHolder is not visible, then the adapter should take care of it if
// it does become visible. // it does become visible.
val viewHolder = recycler.getViewHolderAt(pos) val viewHolder = recycler.findViewHolderForAdapterPosition(pos)
return if (viewHolder is Highlightable) { return if (viewHolder is Highlightable) {
viewHolder.setHighlighted(true) viewHolder.setHighlighted(true)

View file

@ -114,7 +114,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
BindingViewHolder<Genre, DetailAdapter.Listener>(binding.root) { BindingViewHolder<Genre, DetailAdapter.Listener>(binding.root) {
override fun bind(item: Genre, listener: DetailAdapter.Listener) { override fun bind(item: Genre, listener: DetailAdapter.Listener) {
binding.detailCover.bindGenreImage(item) binding.detailCover.bindGenreImage(item)
binding.detailName.textSafe = item.resolvedName binding.detailName.textSafe = item.resolveName(binding.context)
binding.detailSubhead.textSafe = binding.detailSubhead.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)
binding.detailInfo.textSafe = item.totalDuration binding.detailInfo.textSafe = item.totalDuration
@ -135,7 +135,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
val DIFFER = val DIFFER =
object : SimpleItemCallback<Genre>() { object : SimpleItemCallback<Genre>() {
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) = override fun areItemsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.totalDuration == newItem.totalDuration oldItem.totalDuration == newItem.totalDuration
} }
@ -146,8 +146,8 @@ class GenreSongViewHolder private constructor(private val binding: ItemSongBindi
BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable { BindingViewHolder<Song, MenuItemListener>(binding.root), Highlightable {
override fun bind(item: Song, listener: MenuItemListener) { override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(item) binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName binding.songName.textSafe = item.resolveName(binding.context)
binding.songInfo.textSafe = item.resolvedArtistName binding.songInfo.textSafe = item.resolveIndividualArtistName(binding.context)
binding.root.apply { binding.root.apply {
setOnClickListener { listener.onItemClick(item) } setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view -> setOnLongClickListener { view ->

View file

@ -27,6 +27,7 @@ import org.oxycblt.auxio.util.logD
* - On small screens, use only an icon * - On small screens, use only an icon
* - On medium screens, use only text * - On medium screens, use only text
* - On large screens, use text and an icon * - On large screens, use text and an icon
* @author OxygenCobalt
*/ */
class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) : class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) :
TabLayoutMediator.TabConfigurationStrategy { TabLayoutMediator.TabConfigurationStrategy {

View file

@ -29,7 +29,6 @@ import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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) thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
// Get the popup text. If there is none, we default to "?".
val firstPos = firstAdapterPos val firstPos = firstAdapterPos
val popupText = val popupText =
if (firstPos != NO_POSITION) { if (firstPos != NO_POSITION) {
@ -218,60 +218,53 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} else { } else {
null null
} }
?: "?"
popupView.isInvisible = popupText == null val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupText != null) { if (popupView.text != popupText) {
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams popupView.text = popupText
if (popupView.text != popupText) { val widthMeasureSpec =
popupView.text = popupText ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
thumbPadding.left +
thumbPadding.right +
thumbWidth +
popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
popupLayoutParams.width)
val widthMeasureSpec = val heightMeasureSpec =
ViewGroup.getChildMeasureSpec( ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
thumbPadding.left + thumbPadding.top +
thumbPadding.right + thumbPadding.bottom +
thumbWidth + popupLayoutParams.topMargin +
popupLayoutParams.leftMargin + popupLayoutParams.bottomMargin,
popupLayoutParams.rightMargin, popupLayoutParams.height)
popupLayoutParams.width)
val heightMeasureSpec = popupView.measure(widthMeasureSpec, heightMeasureSpec)
ViewGroup.getChildMeasureSpec( }
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
thumbPadding.top +
thumbPadding.bottom +
popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin,
popupLayoutParams.height)
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 popupAnchorY = popupHeight / 2
val popupHeight = popupView.measuredHeight val thumbAnchorY = thumbView.paddingTop
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 popupTop =
val thumbAnchorY = thumbView.paddingTop (thumbTop + thumbAnchorY - popupAnchorY).clamp(
thumbPadding.top + popupLayoutParams.topMargin,
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
val popupTop = popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
(thumbTop + thumbAnchorY - popupAnchorY).clamp(
thumbPadding.top + popupLayoutParams.topMargin,
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
}
} }
override fun onScrolled(dx: Int, dy: Int) { 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.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -55,13 +54,13 @@ class AlbumListFragment : HomeListFragment<Album>() {
// Change how we display the popup depending on the mode. // Change how we display the popup depending on the mode.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) { return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS)) {
// By Name -> Use Name // By Name -> Use Name
is Sort.ByName -> album.resolvedName.sliceArticle().first().uppercase() is Sort.ByName -> album.sortName.first().uppercase()
// By Artist -> Use Artist Name // 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 // 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 // Unsupported sort, error gracefully
else -> null 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.MonoAdapter
import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -48,11 +47,7 @@ class ArtistListFragment : HomeListFragment<Artist>() {
} }
override fun getPopup(pos: Int) = override fun getPopup(pos: Int) =
unlikelyToBeNull(homeModel.artists.value)[pos] unlikelyToBeNull(homeModel.artists.value)[pos].sortName?.run { first().uppercase() }
.resolvedName
.sliceArticle()
.first()
.uppercase()
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
check(item is Music) 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.MonoAdapter
import org.oxycblt.auxio.ui.PrimitiveBackingData import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -48,11 +47,7 @@ class GenreListFragment : HomeListFragment<Genre>() {
} }
override fun getPopup(pos: Int) = override fun getPopup(pos: Int) =
unlikelyToBeNull(homeModel.genres.value)[pos] unlikelyToBeNull(homeModel.genres.value)[pos].sortName?.run { first().uppercase() }
.resolvedName
.sliceArticle()
.first()
.uppercase()
override fun onItemClick(item: Item) { override fun onItemClick(item: Item) {
check(item is Music) 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.SongViewHolder
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.ui.sliceArticle
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -48,7 +47,7 @@ class SongListFragment : HomeListFragment<Song>() {
homeModel.songs.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) } 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] val song = unlikelyToBeNull(homeModel.songs.value)[pos]
// Change how we display the popup depending on the mode. // 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. // based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) { return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
// Name -> Use name // Name -> Use name
is Sort.ByName -> song.resolvedName.sliceArticle().first().uppercase() is Sort.ByName -> song.sortName.first().uppercase()
// Artist -> Use Artist Name // 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 // 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 // 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. * A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. * 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() { class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {
override fun getMovementFlags( override fun getMovementFlags(

View file

@ -18,22 +18,32 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.ContentUris import android.content.ContentUris
import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.MediaStore 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.ui.Item
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
// --- MUSIC MODELS --- // --- MUSIC MODELS ---
/** [Item] variant that represents a music item. */ /**
* [Item] variant that represents a music item.
*/
sealed class Music : Item() { sealed class Music : Item() {
/** The raw name of this item. */ /** The raw name of this item. Null if unknown. */
abstract val rawName: String 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" * Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would
* would become Unknown Artist, (124) would become its proper genre name, etc. * 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 return result
} }
override val resolvedName: String override val sortName: String
get() = rawName get() = rawName.withoutArticle
override fun resolveName(context: Context) = rawName
/** The URI for this song. */ /** The URI for this song. */
val uri: Uri val uri: Uri
@ -96,13 +108,19 @@ data class Song(
val genre: Genre val genre: Genre
get() = unlikelyToBeNull(mGenre) get() = unlikelyToBeNull(mGenre)
/** An album name resolved to this song in particular. */ /**
val resolvedAlbumName: String * The raw artist name for this song in particular. First uses the artist tag, and then falls
get() = album.resolvedName * 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 * Resolve the artist name for this song in particular. First uses the artist tag, and
get() = internalMediaStoreArtistName ?: album.artist.resolvedName * 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. */ /** Internal field. Do not use. */
val internalAlbumGroupingId: Long val internalAlbumGroupingId: Long
@ -165,8 +183,10 @@ data class Album(
return result return result
} }
override val resolvedName: String override val sortName: String
get() = rawName get() = rawName.withoutArticle
override fun resolveName(context: Context) = rawName
/** The formatted total duration of this album */ /** The formatted total duration of this album */
val totalDuration: String val totalDuration: String
@ -177,10 +197,6 @@ data class Album(
val artist: Artist val artist: Artist
get() = unlikelyToBeNull(mArtist) get() = unlikelyToBeNull(mArtist)
/** The artist name, resolved to this album in particular. */
val resolvedArtistName: String
get() = artist.resolvedName
/** Internal field. Do not use. */ /** Internal field. Do not use. */
val internalArtistGroupingId: Long val internalArtistGroupingId: Long
get() = internalGroupingArtistName.lowercase().hashCode().toLong() get() = internalGroupingArtistName.lowercase().hashCode().toLong()
@ -200,8 +216,7 @@ data class Album(
* artist or artist field, not the individual performers of an artist. * artist or artist field, not the individual performers of an artist.
*/ */
data class Artist( data class Artist(
override val rawName: String, override val rawName: String?,
override val resolvedName: String,
/** The albums of this artist. */ /** The albums of this artist. */
val albums: List<Album> val albums: List<Album>
) : MusicParent() { ) : 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. */ /** The songs of this artist. */
val songs = albums.flatMap { it.songs } val songs = albums.flatMap { it.songs }
} }
/** The data object for a genre. */ /** The data object for a genre. */
data class Genre( data class Genre(override val rawName: String?, val songs: List<Song>) : MusicParent() {
override val rawName: String,
override val resolvedName: String,
val songs: List<Song>
) : MusicParent() {
init { init {
for (song in songs) { for (song in songs) {
song.internalLinkGenre(this) 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 */ /** The formatted total duration of this genre */
val totalDuration: String val totalDuration: String
get() = songs.sumOf { it.seconds }.toDuration(false) 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 android.provider.MediaStore
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull 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.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -104,7 +102,7 @@ class MusicLoader {
if (songs.isEmpty()) return null if (songs.isEmpty()) return null
val albums = buildAlbums(songs) val albums = buildAlbums(songs)
val artists = buildArtists(context, albums) val artists = buildArtists(albums)
val genres = readGenres(context, songs) val genres = readGenres(context, songs)
// Sanity check: Ensure that all songs are linked up to albums/artists/genres. // 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 * Group up albums into artists. This also requires a de-duplication step due to some edge cases
* where [buildAlbums] could not detect duplicates. * 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 artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it.internalArtistGroupingId } val albumsByArtist = albums.groupBy { it.internalArtistGroupingId }
for (entry in albumsByArtist) { for (entry in albumsByArtist) {
val templateAlbum = entry.value[0] val templateAlbum = entry.value[0]
val artistName = templateAlbum.internalGroupingArtistName val artistName =
val resolvedName =
when (templateAlbum.internalGroupingArtistName) { when (templateAlbum.internalGroupingArtistName) {
MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist) MediaStore.UNKNOWN_STRING -> null
else -> artistName else -> templateAlbum.internalGroupingArtistName
} }
val artistAlbums = entry.value val artistAlbums = entry.value
artists.add(Artist(artistName, resolvedName, artistAlbums)) artists.add(Artist(artistName, artistAlbums))
} }
logD("Successfully built ${artists.size} artists") logD("Successfully built ${artists.size} artists")
@ -361,21 +358,16 @@ class MusicLoader {
// anyway, so we skip genres that have them. // anyway, so we skip genres that have them.
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue val name = cursor.getStringOrNull(nameIndex) ?: continue
val resolvedName = name.genreNameCompat ?: name
val genreSongs = queryGenreSongs(context, id, songs) ?: continue val genreSongs = queryGenreSongs(context, id, songs) ?: continue
genres.add(Genre(name, resolvedName, genreSongs)) genres.add(Genre(name, genreSongs))
} }
} }
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre } val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
if (songsWithoutGenres.isNotEmpty()) { if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre. // Songs that don't have a genre will be thrown into an unknown genre.
val unknownGenre = val unknownGenre = Genre(null, songsWithoutGenres)
Genre(
MediaStore.UNKNOWN_STRING,
context.getString(R.string.def_genre),
songsWithoutGenres)
genres.add(unknownGenre) genres.add(unknownGenre)
} }
@ -385,30 +377,6 @@ class MusicLoader {
return genres 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 * 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. * some reason, so if that's the case then this function will return null.
@ -446,210 +414,5 @@ class MusicLoader {
*/ */
@Suppress("InlinedApi") @Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST 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.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.util.assertBackgroundThread
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll 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 * 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. */ /** Write a list of [paths] to the database. */
fun writePaths(paths: List<String>) { fun writePaths(paths: List<String>) {
assertBackgroundThread() requireBackgroundThread()
writableDatabase.transaction { writableDatabase.transaction {
delete(TABLE_NAME, null, null) 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. */ /** Get the current list of paths from the database. */
fun readPaths(): List<String> { fun readPaths(): List<String> {
assertBackgroundThread() requireBackgroundThread()
val paths = mutableListOf<String>() val paths = mutableListOf<String>()
readableDatabase.queryAll(TABLE_NAME) { cursor -> 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.R
import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
@ -93,22 +94,28 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
// -- VIEWMODEL SETUP --- // -- VIEWMODEL SETUP ---
playbackModel.song.observe(viewLifecycleOwner) { song -> playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
if (song != null) { playbackModel.isPlaying.observe(viewLifecycleOwner, ::updateIsPlaying)
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.isPlaying.observe(viewLifecycleOwner) { isPlaying -> playbackModel.positionSeconds.observe(viewLifecycleOwner, ::updatePosition)
binding.playbackPlayPause.isActivated = isPlaying }
}
playbackModel.positionSeconds.observe(viewLifecycleOwner) { position -> private fun updateSong(song: Song?) {
binding.playbackProgressBar.progress = position.toInt() 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 if (song == null) return
val binding = requireBinding() val binding = requireBinding()
val context = requireContext()
binding.playbackCover.bindAlbumCover(song) binding.playbackCover.bindAlbumCover(song)
binding.playbackSong.textSafe = song.resolvedName binding.playbackSong.textSafe = song.resolveName(context)
binding.playbackArtist.textSafe = song.resolvedArtistName binding.playbackArtist.textSafe = song.resolveIndividualArtistName(context)
binding.playbackAlbum.textSafe = song.resolvedAlbumName binding.playbackAlbum.textSafe = song.album.resolveName(context)
// Normally if a song had a duration // Normally if a song had a duration
val seconds = song.seconds val seconds = song.seconds
@ -185,7 +186,7 @@ class PlaybackPanelFragment :
private fun updateParent(parent: MusicParent?) { private fun updateParent(parent: MusicParent?) {
requireBinding().playbackToolbar.subtitle = 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) { 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.BindingViewHolder
import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.SongViewHolder import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.disableDropShadowCompat import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.stateList import org.oxycblt.auxio.util.stateList
@ -71,8 +72,8 @@ private constructor(
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun bind(item: Song, listener: QueueItemListener) { override fun bind(item: Song, listener: QueueItemListener) {
binding.songAlbumCover.bindAlbumCover(item) binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName binding.songName.textSafe = item.resolveName(binding.context)
binding.songInfo.textSafe = item.resolvedArtistName binding.songInfo.textSafe = item.resolveIndividualArtistName(binding.context)
binding.background.isInvisible = true 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.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.assertBackgroundThread
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll 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. * 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. * @return The stored [SavedState], null if there isn't one.
*/ */
fun readState(musicStore: MusicStore): SavedState? { fun readState(musicStore: MusicStore): SavedState? {
assertBackgroundThread() requireBackgroundThread()
var state: SavedState? = null var state: SavedState? = null
@ -157,7 +157,7 @@ class PlaybackStateDatabase(context: Context) :
/** Clear the previously written [SavedState] and write a new one. */ /** Clear the previously written [SavedState] and write a new one. */
fun writeState(state: SavedState) { fun writeState(state: SavedState) {
assertBackgroundThread() requireBackgroundThread()
writableDatabase.transaction { writableDatabase.transaction {
delete(TABLE_NAME_STATE, null, null) 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 * @param musicStore Required to transform database songs into actual song instances
*/ */
fun readQueue(musicStore: MusicStore): MutableList<Song> { fun readQueue(musicStore: MusicStore): MutableList<Song> {
assertBackgroundThread() requireBackgroundThread()
val queue = mutableListOf<Song>() val queue = mutableListOf<Song>()
@ -210,7 +210,7 @@ class PlaybackStateDatabase(context: Context) :
/** Write a queue to the database. */ /** Write a queue to the database. */
fun writeQueue(queue: MutableList<Song>) { fun writeQueue(queue: MutableList<Song>) {
assertBackgroundThread() requireBackgroundThread()
val database = writableDatabase val database = writableDatabase
database.transaction { delete(TABLE_NAME_QUEUE, null, null) } 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 * @param onDone What to do when the loading of the album art is finished
*/ */
fun setMetadata(song: Song, onDone: () -> Unit) { fun setMetadata(song: Song, onDone: () -> Unit) {
setContentTitle(song.resolvedName) setContentTitle(song.resolveName(context))
setContentText(song.resolvedArtistName) setContentText(song.resolveIndividualArtistName(context))
// On older versions of android [API <24], show the song's album on the subtext instead of // 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. // the current mode, as that makes more sense for the old style of media notifications.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { 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 // 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 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
// A blank parent always means that the mode is ALL_SONGS // 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 --- // --- NOTIFICATION ACTION BUILDERS ---

View file

@ -114,17 +114,18 @@ class PlaybackSessionConnector(
return return
} }
val artistName = song.resolvedArtistName val artistName = song.resolveIndividualArtistName(context)
val builder = val builder =
MediaMetadataCompat.Builder() MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.resolvedName) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.resolveName(context))
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.resolvedName) .putString(
MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.resolveName(context))
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName) .putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, 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) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
// Load the cover asynchronously. This is the entire reason I don't use a plain // 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.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Header import org.oxycblt.auxio.ui.Header
@ -56,6 +58,7 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemLis
private val searchModel: SearchViewModel by viewModels() private val searchModel: SearchViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val searchAdapter = SearchAdapter(this) private val searchAdapter = SearchAdapter(this)
private var imm: InputMethodManager? = null private var imm: InputMethodManager? = null
@ -74,7 +77,7 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemLis
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
if (item.itemId != R.id.submenu_filtering) { if (item.itemId != R.id.submenu_filtering) {
searchModel.updateFilterModeWithId(item.itemId) searchModel.updateFilterModeWithId(context, item.itemId)
item.isChecked = true item.isChecked = true
true true
} else { } else {
@ -86,7 +89,7 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemLis
binding.searchEditText.apply { binding.searchEditText.apply {
addTextChangedListener { text -> addTextChangedListener { text ->
// Run the search with the updated text as the query // Run the search with the updated text as the query
searchModel.search(text?.toString() ?: "") searchModel.search(context, text?.toString())
} }
if (!launchedKeyboard) { if (!launchedKeyboard) {
@ -109,6 +112,7 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemLis
searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults) searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation) navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse)
} }
override fun onResume() { override fun onResume() {
@ -164,6 +168,12 @@ class SearchFragment : ViewBindingFragment<FragmentSearchBinding>(), MenuItemLis
requireImm().hide() requireImm().hide()
} }
private fun handleLoaderResponse(response: MusicStore.Response?) {
if (response is MusicStore.Response.Ok) {
searchModel.refresh(requireContext())
}
}
private fun requireImm(): InputMethodManager { private fun requireImm(): InputMethodManager {
requireAttached() requireAttached()
val instance = imm val instance = imm

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio.search package org.oxycblt.auxio.search
import android.content.Context
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -42,7 +43,7 @@ class SearchViewModel : ViewModel() {
private val mSearchResults = MutableLiveData(listOf<Item>()) private val mSearchResults = MutableLiveData(listOf<Item>())
private var mIsNavigating = false private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null private var mFilterMode: DisplayMode? = null
private var mLastQuery = "" private var mLastQuery: String? = null
/** Current search results from the last [search] call. */ /** Current search results from the last [search] call. */
val searchResults: LiveData<List<Item>> val searchResults: LiveData<List<Item>>
@ -54,21 +55,16 @@ class SearchViewModel : ViewModel() {
init { init {
mFilterMode = settingsManager.searchFilterMode mFilterMode = settingsManager.searchFilterMode
viewModelScope.launch {
MusicStore.awaitInstance()
search(mLastQuery)
}
} }
/** /**
* Use [query] to perform a search of the music library. Will push results to [searchResults]. * 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() val musicStore = MusicStore.maybeGetInstance()
mLastQuery = query mLastQuery = query
if (query.isEmpty() || musicStore == null) { if (query.isNullOrEmpty() || musicStore == null) {
logD("No music/query, ignoring search") logD("No music/query, ignoring search")
mSearchResults.value = listOf() mSearchResults.value = listOf()
return return
@ -84,28 +80,28 @@ class SearchViewModel : ViewModel() {
// Note: a filter mode of null means to not filter at all. // Note: a filter mode of null means to not filter at all.
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) { 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.add(Header(-1, R.string.lbl_artists))
results.addAll(sort.artists(artists)) results.addAll(sort.artists(artists))
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) { 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.add(Header(-2, R.string.lbl_albums))
results.addAll(sort.albums(albums)) results.addAll(sort.albums(albums))
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) { 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.add(Header(-3, R.string.lbl_genres))
results.addAll(sort.genres(genres)) results.addAll(sort.genres(genres))
} }
} }
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) { 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.add(Header(-4, R.string.lbl_songs))
results.addAll(sort.songs(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]. * 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 = mFilterMode =
when (id) { when (id) {
R.id.option_filter_songs -> DisplayMode.SHOW_SONGS R.id.option_filter_songs -> DisplayMode.SHOW_SONGS
@ -132,33 +133,33 @@ class SearchViewModel : ViewModel() {
settingsManager.searchFilterMode = mFilterMode 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 * Shortcut that will run a ignoreCase filter on a list and only return a value if the resulting
* list is empty. * 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 { val filtered = filter {
// First see if the normal item name will work. If that fails, try the "normalized" // 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 // [e.g all accented/unicode chars become latin chars] instead. Hopefully this
// shouldn't break other language's search functionality. // shouldn't break other language's search functionality.
it.resolvedName.contains(value, ignoreCase = true) || it.resolveNameNormalized(context).contains(value, ignoreCase = true) ||
it.resolvedName.normalized().contains(value, ignoreCase = true) it.resolveNameNormalized(context).contains(value, ignoreCase = true)
} }
return filtered.ifEmpty { null } 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 // This method normalizes strings so that songs with accented characters will show
// up in search even if the actual character was not inputted. // up in search even if the actual character was not inputted.
// https://stackoverflow.com/a/32030586/14143986 // https://stackoverflow.com/a/32030586/14143986
// Normalize with NFKD [Meaning that symbols with identical meanings will be turned into // Normalize with NFKD [Meaning that symbols with identical meanings will be turned into
// their letter variants]. // 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 // 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. // 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 androidx.lifecycle.ViewModel
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
/** /** A ViewModel that handles complicated navigation situations. */
* A ViewModel that handles complicated navigation situations.
*/
class NavigationViewModel : ViewModel() { class NavigationViewModel : ViewModel() {
private val mMainNavigationAction = MutableLiveData<MainNavigationAction?>() private val mMainNavigationAction = MutableLiveData<MainNavigationAction?>()
/** Flag for main fragment navigation. Intended for MainFragment use only. */ /** Flag for main fragment navigation. Intended for MainFragment use only. */
@ -36,9 +34,7 @@ class NavigationViewModel : ViewModel() {
val exploreNavigationItem: LiveData<Music?> val exploreNavigationItem: LiveData<Music?>
get() = mExploreNavigationItem 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) { fun mainNavigateTo(action: MainNavigationAction) {
if (mMainNavigationAction.value != null) return if (mMainNavigationAction.value != null) return
mMainNavigationAction.value = action mMainNavigationAction.value = action

View file

@ -42,6 +42,8 @@ import org.oxycblt.auxio.util.logW
* representing whether this sort is ascending or descending. * representing whether this sort is ascending or descending.
* *
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Make comparators static instances
*/ */
sealed class Sort(open val isAscending: Boolean) { sealed class Sort(open val isAscending: Boolean) {
protected abstract val sortIntCode: Int protected abstract val sortIntCode: Int
@ -240,9 +242,16 @@ sealed class Sort(open val isAscending: Boolean) {
class NameComparator<T : Music> : Comparator<T> { class NameComparator<T : Music> : Comparator<T> {
override fun compare(a: T, b: T): Int { override fun compare(a: T, b: T): Int {
return a.resolvedName val aSortName = a.sortName
.sliceArticle() val bSortName = b.sortName
.compareTo(b.resolvedName.sliceArticle(), ignoreCase = true) 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) { BindingViewHolder<Song, MenuItemListener>(binding.root) {
override fun bind(item: Song, listener: MenuItemListener) { override fun bind(item: Song, listener: MenuItemListener) {
binding.songAlbumCover.bindAlbumCover(item) binding.songAlbumCover.bindAlbumCover(item)
binding.songName.textSafe = item.resolvedName binding.songName.textSafe = item.resolveName(binding.context)
binding.songInfo.textSafe = item.resolvedArtistName binding.songInfo.textSafe = item.resolveIndividualArtistName(binding.context)
binding.root.apply { binding.root.apply {
setOnClickListener { listener.onItemClick(item) } setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view -> setOnLongClickListener { view ->
@ -64,8 +64,8 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
val DIFFER = val DIFFER =
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) = override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName &&
oldItem.resolvedArtistName == oldItem.resolvedArtistName oldItem.individualRawArtistName == oldItem.individualRawArtistName
} }
} }
} }
@ -78,8 +78,8 @@ private constructor(
override fun bind(item: Album, listener: MenuItemListener) { override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bindAlbumCover(item) binding.parentImage.bindAlbumCover(item)
binding.parentName.textSafe = item.resolvedName binding.parentName.textSafe = item.resolveName(binding.context)
binding.parentInfo.textSafe = item.resolvedArtistName binding.parentInfo.textSafe = item.artist.resolveName(binding.context)
binding.root.apply { binding.root.apply {
setOnClickListener { listener.onItemClick(item) } setOnClickListener { listener.onItemClick(item) }
setOnLongClickListener { view -> setOnLongClickListener { view ->
@ -102,8 +102,8 @@ private constructor(
val DIFFER = val DIFFER =
object : SimpleItemCallback<Album>() { object : SimpleItemCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) = override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName &&
oldItem.resolvedArtistName == newItem.resolvedArtistName 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) { override fun bind(item: Artist, listener: MenuItemListener) {
binding.parentImage.bindArtistImage(item) binding.parentImage.bindArtistImage(item)
binding.parentName.textSafe = item.resolvedName binding.parentName.textSafe = item.resolveName(binding.context)
binding.parentInfo.textSafe = binding.parentInfo.textSafe =
binding.context.getString( binding.context.getString(
R.string.fmt_two, R.string.fmt_two,
@ -142,7 +142,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
val DIFFER = val DIFFER =
object : SimpleItemCallback<Artist>() { object : SimpleItemCallback<Artist>() {
override fun areItemsTheSame(oldItem: Artist, newItem: Artist) = override fun areItemsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName &&
oldItem.albums.size == newItem.albums.size && oldItem.albums.size == newItem.albums.size &&
newItem.songs.size == newItem.songs.size newItem.songs.size == newItem.songs.size
} }
@ -157,7 +157,7 @@ private constructor(
override fun bind(item: Genre, listener: MenuItemListener) { override fun bind(item: Genre, listener: MenuItemListener) {
binding.parentImage.bindGenreImage(item) binding.parentImage.bindGenreImage(item)
binding.parentName.textSafe = item.resolvedName binding.parentName.textSafe = item.resolveName(binding.context)
binding.parentInfo.textSafe = binding.parentInfo.textSafe =
binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size) binding.context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size)
binding.root.apply { binding.root.apply {
@ -182,8 +182,7 @@ private constructor(
val DIFFER = val DIFFER =
object : SimpleItemCallback<Genre>() { object : SimpleItemCallback<Genre>() {
override fun areItemsTheSame(oldItem: Genre, newItem: Genre): Boolean = override fun areItemsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.resolvedName == newItem.resolvedName && oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
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 { fun View.isUnder(x: Float, y: Float, minTouchTargetSize: Int = 0): Boolean {
return isUnderImpl(x, left, right, (parent as View).width, minTouchTargetSize) && return isUnderImpl(x, left, right, (parent as View).width, minTouchTargetSize) &&
isUnderImpl(y, top, bottom, (parent as View).height, minTouchTargetSize) isUnderImpl(y, top, bottom, (parent as View).height, minTouchTargetSize)
@ -85,15 +89,22 @@ private fun isUnderImpl(
return position >= touchTargetStart && position < touchTargetEnd return position >= touchTargetStart && position < touchTargetEnd
} }
/** Returns if this view is RTL in a compatible manner. */
val View.isRtl: Boolean val View.isRtl: Boolean
get() = layoutDirection == View.LAYOUT_DIRECTION_RTL get() = layoutDirection == View.LAYOUT_DIRECTION_RTL
/** Returns if this drawable is RTL in a compatible manner.] */
val Drawable.isRtl: Boolean val Drawable.isRtl: Boolean
get() = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL get() = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
/** Shortcut to get a context from a ViewBinding */
val ViewBinding.context: Context val ViewBinding.context: Context
get() = root.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 var TextView.textSafe: CharSequence
get() = text get() = text
set(value) { 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. */ /** Returns whether a recyclerview can scroll. */
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height

View file

@ -38,18 +38,18 @@ fun Any.logD(obj: Any) {
fun Any.logD(msg: String) { fun Any.logD(msg: String) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
basedCopyleftNotice() basedCopyleftNotice()
Log.d(getName(), msg) Log.d(name, msg)
} }
} }
/** Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects */ /** Shortcut method for logging [msg] as a warning to the console. Handles anonymous objects */
fun Any.logW(msg: String) { 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 */ /** Shortcut method for logging [msg] as an error to the console. Handles anonymous objects */
fun Any.logE(msg: String) { 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 * Get a non-nullable name, used so that logs will always show up by Auxio
* @return The name of the object, otherwise "Anonymous Object" * @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. * 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 import org.oxycblt.auxio.BuildConfig
/** Assert that we are on a background thread. */ /** Assert that we are on a background thread. */
fun assertBackgroundThread() { fun requireBackgroundThread() {
check(Looper.myLooper() != Looper.getMainLooper()) { check(Looper.myLooper() != Looper.getMainLooper()) {
"This operation must be ran on a background thread" "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) 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 { private fun RemoteViews.applyMeta(context: Context, state: WidgetState): RemoteViews {
applyCover(context, state) applyCover(context, state)
setTextViewText(R.id.widget_song, state.song.resolvedName) setTextViewText(R.id.widget_song, state.song.resolveName(context))
setTextViewText(R.id.widget_artist, state.song.resolvedArtistName) setTextViewText(R.id.widget_artist, state.song.resolveIndividualArtistName(context))
return this return this
} }
@ -101,7 +101,7 @@ private fun RemoteViews.applyCover(context: Context, state: WidgetState): Remote
setImageViewBitmap(R.id.widget_cover, state.albumArt) setImageViewBitmap(R.id.widget_cover, state.albumArt)
setContentDescription( setContentDescription(
R.id.widget_cover, 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 { } else {
setImageViewResource(R.id.widget_cover, R.drawable.ic_widget_album) setImageViewResource(R.id.widget_cover, R.drawable.ic_widget_album)
setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover)) 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`). - `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 - `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. 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, - `Header` corresponds to a simple header. The Detail UIs have a derivative called `SortHeader` that also adds a sorting button.
while `ActionHeader` corresponds to an action with a dedicated icon, such as with sorting.
Other data types represent a specific UI configuration or state: Other data types represent a specific UI configuration or state:
- Sealed classes like `Sort` contain data with them that can be modified. - 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 - 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" 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. 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. - `rawName` is used when doing internal work, such as saving music data or diffing items
- For `Song` instances in particular, prefer `resolvedAlbumName` and `resolvedArtistName` over `album.resolvedName` and `album.artist.resolvedName`, - `sortName` is used in the fast scroller indicators and sorting. Avoid it wherever else.
as these resolve the name in context of the song. - `resolveName()` should be used when displaying any kind of music data to the user.
- For `Album` instances in particular, prefer `resolvedArtistName` over `artist.resolvedName`, which don't actually do anything but add consistency - For songs, `individualArtistRawName` and `resolveIndividualArtistName` should always be used when displaying the artist of
to the `Song` function a song, as it will always show collaborator information first before deatiling to the album artist.
#### Music Access #### 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**. All music on a system is asynchronously loaded into the shared object `MusicStore`. Because of this, **`MusicStore` may not be available at all times**.