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:
parent
ab194c14c2
commit
3a19d822ce
36 changed files with 534 additions and 506 deletions
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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 ---
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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**.
|
||||
|
|
Loading…
Reference in a new issue