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
|
- 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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
|
@ -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 ---
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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**.
|
||||||
|
|
Loading…
Reference in a new issue