all: switch to stateflow

Switch from LiveData to StateFlow.

While LiveData is a pretty good data storage/observer mechanism, it has
a few flaws:
- Values are always nullable in LiveData, even if you make them
non-null.
- LiveData can only be mutated on Dispatchers.Main, which frustrates
possible additions like a more fine-grained music status system.
- LiveData's perks are exclusive to ViewModels, which made coupling
with shared objects somewhat cumbersome.

StateFlow solves all of these by being a native coroutine solution with
proper android bindings. Use it instead.
This commit is contained in:
OxygenCobalt 2022-06-01 11:46:00 -06:00
parent 402a290db7
commit a65d37c421
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
32 changed files with 268 additions and 232 deletions

View file

@ -2,6 +2,9 @@
## dev [v2.3.1, v2.4.0, or v3.0.0]
#### What's Improved
- Loading UI is now more clear and easy-to-use
#### What's Fixed
- Fixed crash when seeking to the end of a track as the track changed to a track with a lower duration

View file

@ -22,7 +22,7 @@ android {
compileSdkVersion 32
buildToolsVersion "32.0.0"
// ExoPlayer needs Java 8 to compile.
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
compileOptions {
targetCompatibility JavaVersion.VERSION_1_8
@ -76,6 +76,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
// Navigation
implementation "androidx.navigation:navigation-ui-ktx:$navigation_version"
@ -91,7 +92,7 @@ dependencies {
// --- THIRD PARTY ---
// Exoplayer
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION.
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT.
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
implementation "com.google.android.exoplayer:exoplayer-core:2.17.1"
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
@ -109,7 +110,7 @@ dependencies {
spotless {
kotlin {
target "src/**/*.kt"
ktfmt('0.37').dropboxStyle()
ktfmt("0.37").dropboxStyle()
licenseHeaderFile("NOTICE")
}
}

View file

@ -22,10 +22,10 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.collect
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicViewModel
@ -34,6 +34,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.launch
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
@ -50,12 +51,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
// --- UI SETUP ---
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reloadMusic(requireContext())
}
requireActivity()
.onBackPressedDispatcher.addCallback(
viewLifecycleOwner, DynamicBackPressedCallback().also { callback = it })
@ -81,10 +76,9 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// TODO: Move this to a service [automatic rescanning]
musicModel.loadMusic(requireContext())
navModel.mainNavigationAction.observe(viewLifecycleOwner, ::handleMainNavigation)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleExploreNavigation)
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
launch { navModel.mainNavigationAction.collect(::handleMainNavigation) }
launch { navModel.exploreNavigationItem.collect(::handleExploreNavigation) }
launch { playbackModel.song.collect(::updateSong) }
}
override fun onResume() {

View file

@ -25,6 +25,7 @@ import androidx.core.view.children
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
@ -39,6 +40,7 @@ import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast
@ -66,9 +68,9 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
// -- VIEWMODEL SETUP ---
detailModel.albumData.observe(viewLifecycleOwner, detailAdapter.data::submitList)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
launch { detailModel.albumData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collect(::updateSong) }
}
override fun onMenuItemClick(item: MenuItem): Boolean {

View file

@ -22,6 +22,7 @@ import android.view.MenuItem
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
@ -37,6 +38,7 @@ import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -64,10 +66,10 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
// --- VIEWMODEL SETUP ---
detailModel.artistData.observe(viewLifecycleOwner, detailAdapter.data::submitList)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
playbackModel.parent.observe(viewLifecycleOwner, ::updateParent)
launch { detailModel.artistData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.parent.collect(::updateParent) }
}
override fun onMenuItemClick(item: MenuItem): Boolean = false

View file

@ -17,9 +17,9 @@
package org.oxycblt.auxio.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.recycler.DiscHeader
import org.oxycblt.auxio.detail.recycler.SortHeader
@ -45,12 +45,12 @@ class DetailViewModel : ViewModel() {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val _currentAlbum = MutableLiveData<Album?>()
val currentAlbum: LiveData<Album?>
private val _currentAlbum = MutableStateFlow<Album?>(null)
val currentAlbum: StateFlow<Album?>
get() = _currentAlbum
private val _albumData = MutableLiveData(listOf<Item>())
val albumData: LiveData<List<Item>>
private val _albumData = MutableStateFlow(listOf<Item>())
val albumData: StateFlow<List<Item>>
get() = _albumData
var albumSort: Sort
@ -60,12 +60,12 @@ class DetailViewModel : ViewModel() {
currentAlbum.value?.let(::refreshAlbumData)
}
private val _currentArtist = MutableLiveData<Artist?>()
val currentArtist: LiveData<Artist?>
private val _currentArtist = MutableStateFlow<Artist?>(null)
val currentArtist: StateFlow<Artist?>
get() = _currentArtist
private val _artistData = MutableLiveData(listOf<Item>())
val artistData: LiveData<List<Item>> = _artistData
private val _artistData = MutableStateFlow(listOf<Item>())
val artistData: StateFlow<List<Item>> = _artistData
var artistSort: Sort
get() = settingsManager.detailArtistSort
@ -74,12 +74,12 @@ class DetailViewModel : ViewModel() {
currentArtist.value?.let(::refreshArtistData)
}
private val _currentGenre = MutableLiveData<Genre?>()
val currentGenre: LiveData<Genre?>
private val _currentGenre = MutableStateFlow<Genre?>(null)
val currentGenre: StateFlow<Genre?>
get() = _currentGenre
private val _genreData = MutableLiveData(listOf<Item>())
val genreData: LiveData<List<Item>> = _genreData
private val _genreData = MutableStateFlow(listOf<Item>())
val genreData: StateFlow<List<Item>> = _genreData
var genreSort: Sort
get() = settingsManager.detailGenreSort

View file

@ -22,6 +22,7 @@ import android.view.MenuItem
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter
@ -37,6 +38,7 @@ import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -62,9 +64,9 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
// --- VIEWMODEL SETUP ---
detailModel.genreData.observe(viewLifecycleOwner, detailAdapter.data::submitList)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
launch { detailModel.genreData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collect(::updateSong) }
}
override fun onMenuItemClick(item: MenuItem): Boolean = false

View file

@ -35,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.home.list.AlbumListFragment
@ -53,6 +54,7 @@ import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logTraceOrThrow
@ -71,13 +73,14 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
private var sortItem: MenuItem? = null
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
val sortItem: MenuItem
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher =
storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reloadMusic(requireContext())
}
@ -89,8 +92,8 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
updateTabConfiguration()
binding.homeLoadingContainer.setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
binding.homeLoadingContainer.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
}
@ -118,20 +121,18 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// --- VIEWMODEL SETUP ---
homeModel.isFastScrolling.observe(viewLifecycleOwner, ::updateFastScrolling)
homeModel.currentTab.observe(viewLifecycleOwner) { tab -> updateCurrentTab(sortItem, tab) }
homeModel.recreateTabs.observe(viewLifecycleOwner, ::handleRecreateTabs)
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
handleLoaderResponse(response, permLauncher)
}
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
launch { homeModel.isFastScrolling.collect(::updateFastScrolling) }
launch { homeModel.currentTab.collect(::updateCurrentTab) }
launch { homeModel.recreateTabs.collect(::handleRecreateTabs) }
launch { musicModel.response.collect(::handleLoaderResponse) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
}
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
binding.homeToolbar.setOnMenuItemClickListener(null)
storagePermissionLauncher = null
sortItem = null
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@ -178,7 +179,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response.
if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) {
if (musicModel.response.value !is MusicStore.Response.Ok) {
return
}
@ -189,21 +190,21 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun updateCurrentTab(sortItem: MenuItem, tab: DisplayMode) {
private fun updateCurrentTab(tab: DisplayMode) {
// Make sure that we update the scrolling view and allowed menu items whenever
// the tab changes.
val binding = requireBinding()
when (tab) {
DisplayMode.SHOW_SONGS -> {
updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_count }
updateSortMenu(tab) { id -> id != R.id.option_sort_count }
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
}
DisplayMode.SHOW_ALBUMS -> {
updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album }
updateSortMenu(tab) { id -> id != R.id.option_sort_album }
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list
}
DisplayMode.SHOW_ARTISTS -> {
updateSortMenu(sortItem, tab) { id ->
updateSortMenu(tab) { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
@ -212,7 +213,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list
}
DisplayMode.SHOW_GENRES -> {
updateSortMenu(sortItem, tab) { id ->
updateSortMenu(tab) { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
@ -223,14 +224,13 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun updateSortMenu(
item: MenuItem,
displayMode: DisplayMode,
isVisible: (Int) -> Boolean
) {
private fun updateSortMenu(displayMode: DisplayMode, isVisible: (Int) -> Boolean) {
val sortItem =
requireNotNull(sortItem) { "Cannot update sort menu when view does not exist" }
val toHighlight = homeModel.getSortForDisplay(displayMode)
for (option in item.subMenu) {
for (option in sortItem.subMenu) {
if (option.itemId == toHighlight.itemId) {
option.isChecked = true
}
@ -257,10 +257,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun handleLoaderResponse(
response: MusicStore.Response?,
permLauncher: ActivityResultLauncher<String>
) {
private fun handleLoaderResponse(response: MusicStore.Response?) {
val binding = requireBinding()
if (response is MusicStore.Response.Ok) {
@ -296,13 +293,18 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
is MusicStore.Response.NoPerms -> {
val launcher =
requireNotNull(storagePermissionLauncher) {
"Cannot access permission launcher while in non-view state"
}
binding.homeLoadingProgress.visibility = View.INVISIBLE
binding.homeLoadingStatus.textSafe = getString(R.string.err_no_perms)
binding.homeLoadingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_grant)
setOnClickListener {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
launcher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}

View file

@ -17,9 +17,9 @@
package org.oxycblt.auxio.home
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -40,20 +40,20 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val _songs = MutableLiveData(listOf<Song>())
val songs: LiveData<List<Song>>
private val _songs = MutableStateFlow(listOf<Song>())
val songs: StateFlow<List<Song>>
get() = _songs
private val _albums = MutableLiveData(listOf<Album>())
val albums: LiveData<List<Album>>
private val _albums = MutableStateFlow(listOf<Album>())
val albums: StateFlow<List<Album>>
get() = _albums
private val _artists = MutableLiveData(listOf<Artist>())
val artists: LiveData<List<Artist>>
private val _artists = MutableStateFlow(listOf<Artist>())
val artists: MutableStateFlow<List<Artist>>
get() = _artists
private val _genres = MutableLiveData(listOf<Genre>())
val genres: LiveData<List<Genre>>
private val _genres = MutableStateFlow(listOf<Genre>())
val genres: StateFlow<List<Genre>>
get() = _genres
var tabs: List<DisplayMode> = visibleTabs
@ -63,18 +63,18 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
private val visibleTabs: List<DisplayMode>
get() = settingsManager.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
private val _currentTab = MutableLiveData(tabs[0])
val currentTab: LiveData<DisplayMode> = _currentTab
private val _currentTab = MutableStateFlow(tabs[0])
val currentTab: StateFlow<DisplayMode> = _currentTab
/**
* Marker to recreate all library tabs, usually initiated by a settings change. When this flag
* is set, all tabs (and their respective viewpager fragments) will be recreated from scratch.
*/
private val _shouldRecreateTabs = MutableLiveData(false)
val recreateTabs: LiveData<Boolean> = _shouldRecreateTabs
private val _shouldRecreateTabs = MutableStateFlow(false)
val recreateTabs: StateFlow<Boolean> = _shouldRecreateTabs
private val _isFastScrolling = MutableLiveData(false)
val isFastScrolling: LiveData<Boolean> = _isFastScrolling
private val _isFastScrolling = MutableStateFlow(false)
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
init {
musicStore.addCallback(this)
@ -121,7 +121,6 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback
settingsManager.libGenreSort = sort
_genres.value = sort.genres(unlikelyToBeNull(_genres.value))
}
else -> {}
}
}

View file

@ -17,9 +17,11 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.ui.AlbumViewHolder
@ -31,6 +33,7 @@ import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -40,13 +43,15 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class AlbumListFragment : HomeListFragment<Album>() {
private val homeAdapter = AlbumAdapter(this)
override fun setupRecycler(recycler: RecyclerView) {
recycler.apply {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_album_list
adapter = homeAdapter
}
homeModel.albums.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
launch { homeModel.albums.collect(homeAdapter.data::submitList) }
}
override fun getPopup(pos: Int): String? {

View file

@ -17,9 +17,11 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.ui.ArtistViewHolder
@ -31,6 +33,7 @@ import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -40,13 +43,15 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class ArtistListFragment : HomeListFragment<Artist>() {
private val homeAdapter = ArtistAdapter(this)
override fun setupRecycler(recycler: RecyclerView) {
recycler.apply {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_artist_list
adapter = homeAdapter
}
homeModel.artists.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
launch { homeModel.artists.collect(homeAdapter.data::submitList) }
}
override fun getPopup(pos: Int): String? {

View file

@ -17,9 +17,11 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.ui.DisplayMode
@ -31,6 +33,7 @@ import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -40,13 +43,15 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class GenreListFragment : HomeListFragment<Genre>() {
private val homeAdapter = GenreAdapter(this)
override fun setupRecycler(recycler: RecyclerView) {
recycler.apply {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_genre_list
adapter = homeAdapter
}
homeModel.genres.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
launch { homeModel.genres.collect(homeAdapter.data::submitList) }
}
override fun getPopup(pos: Int): String? {

View file

@ -21,7 +21,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
@ -40,8 +39,6 @@ abstract class HomeListFragment<T : Item> :
MenuItemListener,
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.OnFastScrollListener {
abstract fun setupRecycler(recycler: RecyclerView)
protected val playbackModel: PlaybackViewModel by activityViewModels()
protected val navModel: NavigationViewModel by activityViewModels()
protected val homeModel: HomeViewModel by activityViewModels()
@ -50,7 +47,6 @@ abstract class HomeListFragment<T : Item> :
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
setupRecycler(binding.homeRecycler)
binding.homeRecycler.popupProvider = this
binding.homeRecycler.listener = this
}

View file

@ -17,9 +17,11 @@
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Item
@ -30,6 +32,7 @@ import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -39,20 +42,22 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class SongListFragment : HomeListFragment<Song>() {
private val homeAdapter = SongsAdapter(this)
override fun setupRecycler(recycler: RecyclerView) {
recycler.apply {
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_song_list
adapter = homeAdapter
}
homeModel.songs.observe(viewLifecycleOwner) { list -> homeAdapter.data.submitList(list) }
launch { homeModel.songs.collect(homeAdapter.data::submitList) }
}
override fun getPopup(pos: Int): String? {
val song = unlikelyToBeNull(homeModel.songs.value)[pos]
// Change how we display the popup depending on the mode.
// We don't use the more correct resolve(Model)Name here, as sorts are largely
// Note: We don't use the more correct individual artist name here, as sorts are largely
// based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS)) {
// Name -> Use name

View file

@ -137,6 +137,10 @@ data class Song(
return result
}
/** Internal field. Do not use. */
val _genreGroupingId: Long
get() = (_genreName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
/** Internal field. Do not use. */
val _artistGroupingName: String?
get() = _albumArtistName ?: _artistName

View file

@ -74,7 +74,7 @@ class MusicStore private constructor() {
return newResponse
}
private suspend fun loadImpl(context: Context): Response {
private fun loadImpl(context: Context): Response {
val notGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_DENIED
@ -110,19 +110,18 @@ class MusicStore private constructor() {
val albums: List<Album>,
val songs: List<Song>
) {
/** Find a song in a faster manner using an ID for its album as well. */
fun findSongFast(songId: Long, albumId: Long): Song? {
return albums.find { it.id == albumId }?.songs?.find { it.id == songId }
}
/** Find a song in a faster manner by using the album ID as well.. */
fun findSongFast(songId: Long, albumId: Long) =
albums.find { it.id == albumId }.run { songs.find { it.id == songId } }
/**
* Find a song for a [uri], this is similar to [findSongFast], but with some kind of content
* uri.
* @return The corresponding [Song] for this [uri], null if there isn't one.
*/
fun findSongForUri(context: Context, uri: Uri): Song? {
return context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME)) { cursor ->
fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(uri, arrayOf(OpenableColumns.DISPLAY_NAME)) {
cursor ->
cursor.moveToFirst()
val displayName =
@ -130,16 +129,11 @@ class MusicStore private constructor() {
songs.find { it.fileName == displayName }
}
}
}
/**
* A response that [MusicStore] returns when loading music. And before you ask, yes, I do like
* rust.
*/
sealed class Response {
class Ok(val library: Library) : Response()
class Err(throwable: Throwable) : Response()
data class Ok(val library: Library) : Response()
data class Err(val throwable: Throwable) : Response()
object NoMusic : Response()
object NoPerms : Response()
}

View file

@ -18,42 +18,38 @@
package org.oxycblt.auxio.music
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.logD
/** A [ViewModel] that represents the current music indexing state. */
class MusicViewModel : ViewModel(), MusicStore.Callback {
class MusicViewModel : ViewModel() {
private val musicStore = MusicStore.getInstance()
private val _loaderResponse = MutableLiveData<MusicStore.Response?>(null)
val loaderResponse: LiveData<MusicStore.Response?> = _loaderResponse
private val _response = MutableStateFlow<MusicStore.Response?>(null)
val response: StateFlow<MusicStore.Response?> = _response
private var isBusy = false
init {
musicStore.addCallback(this)
}
/**
* Initiate the loading process. This is done here since HomeFragment will be the first fragment
* navigated to and because SnackBars will have the best UX here.
*/
fun loadMusic(context: Context) {
if (_loaderResponse.value != null || isBusy) {
if (_response.value != null || isBusy) {
logD("Loader is busy/already completed, not reloading")
return
}
isBusy = true
_loaderResponse.value = null
_response.value = null
viewModelScope.launch {
val result = musicStore.load(context)
_loaderResponse.value = result
_response.value = result
isBusy = false
}
}
@ -64,16 +60,7 @@ class MusicViewModel : ViewModel(), MusicStore.Callback {
*/
fun reloadMusic(context: Context) {
logD("Reloading music library")
_loaderResponse.value = null
_response.value = null
loadMusic(context)
}
override fun onMusicUpdate(response: MusicStore.Response) {
_loaderResponse.value = response
}
override fun onCleared() {
super.onCleared()
musicStore.removeCallback(this)
}
}

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.databinding.DialogExcludedBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast
@ -92,7 +93,7 @@ class ExcludedDialog :
// --- VIEWMODEL SETUP ---
excludedModel.paths.observe(viewLifecycleOwner, ::updatePaths)
launch { excludedModel.paths.collect(::updatePaths) }
logD("Dialog created")
}

View file

@ -18,12 +18,12 @@
package org.oxycblt.auxio.music.excluded
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.util.logD
@ -37,8 +37,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* TODO: Unify with MusicViewModel
*/
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
private val _paths = MutableLiveData(mutableListOf<String>())
val paths: LiveData<MutableList<String>>
private val _paths = MutableStateFlow(mutableListOf<String>())
val paths: StateFlow<MutableList<String>>
get() = _paths
var isModified: Boolean = false

View file

@ -91,6 +91,8 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
// Spin until all tasks are complete
}
// TODO: Stabilize sorting order
return songs
}
@ -124,9 +126,8 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
// We only support two formats as it stands:
// - ID3v2 text frames
// - Vorbis comments
// This should be enough to cover the vast, vast majority of audio formats.
// It is also assumed that a file only has either ID3v2 text frames or vorbis
// comments.
// TODO: Formats like flac can have both ID3v2 and OGG tags, so we might want to split
// up this logic.
when (val tag = metadata.get(i)) {
is TextInformationFrame ->
if (tag.value.isNotEmpty()) {

View file

@ -191,12 +191,16 @@ object Indexer {
return artists
}
/** Build genres and link them to their particular songs. */
/**
* Group up songs into genres. This is a relatively simple step compared to the other library
* steps, as there is no demand to deduplicate genres by a lowercase name.
*/
private fun buildGenres(songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
val songsByGenre = songs.groupBy { it._genreName?.hashCode() }
val songsByGenre = songs.groupBy { it._genreGroupingId }
for (entry in songsByGenre) {
// The first song fill suffice for template metadata.
val templateSong = entry.value[0]
genres.add(Genre(rawName = templateSong._genreName, songs = entry.value))
}
@ -214,10 +218,4 @@ object Indexer {
/** Create a list of songs from the [Cursor] queried in [query]. */
fun loadSongs(context: Context, cursor: Cursor): Collection<Song>
}
sealed class Event {
object Query : Event()
object LoadSongs : Event()
object BuildLibrary : Event()
}
}

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.getColorStateListSafe
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.systemGestureInsetsCompat
import org.oxycblt.auxio.util.textSafe
@ -78,10 +79,9 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
// -- VIEWMODEL SETUP ---
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
playbackModel.isPlaying.observe(viewLifecycleOwner, ::updateIsPlaying)
playbackModel.positionSecs.observe(viewLifecycleOwner, ::updatePosition)
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.isPlaying.collect(::updateIsPlaying) }
launch { playbackModel.positionSecs.collect(::updatePosition) }
}
private fun updateSong(song: Song?) {

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.textSafe
@ -52,6 +53,8 @@ class PlaybackPanelFragment :
private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private var queueItem: MenuItem? = null
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackPanelBinding.inflate(inflater)
@ -67,8 +70,6 @@ class PlaybackPanelFragment :
insets
}
val queueItem: MenuItem
binding.playbackToolbar.apply {
setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.COLLAPSE) }
setOnMenuItemClickListener(this@PlaybackPanelFragment)
@ -105,18 +106,13 @@ class PlaybackPanelFragment :
// --- VIEWMODEL SETUP --
playbackModel.song.observe(viewLifecycleOwner, ::updateSong)
playbackModel.parent.observe(viewLifecycleOwner, ::updateParent)
playbackModel.positionSecs.observe(viewLifecycleOwner, ::updatePosition)
playbackModel.repeatMode.observe(viewLifecycleOwner, ::updateRepeat)
playbackModel.isPlaying.observe(viewLifecycleOwner, ::updatePlaying)
playbackModel.isShuffled.observe(viewLifecycleOwner, ::updateShuffled)
playbackModel.nextUp.observe(viewLifecycleOwner) { nextUp ->
// The queue icon uses a selector that will automatically tint the icon as active or
// inactive. We just need to set the flag.
queueItem.isEnabled = nextUp.isNotEmpty()
}
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.parent.collect(::updateParent) }
launch { playbackModel.positionSecs.collect(::updatePosition) }
launch { playbackModel.repeatMode.collect(::updateRepeat) }
launch { playbackModel.isPlaying.collect(::updatePlaying) }
launch { playbackModel.isShuffled.collect(::updateShuffled) }
launch { playbackModel.nextUp.collect(::updateNextUp) }
logD("Fragment Created")
}
@ -176,4 +172,9 @@ class PlaybackPanelFragment :
private fun updateShuffled(isShuffled: Boolean) {
requireBinding().playbackShuffle.isActivated = isShuffled
}
private fun updateNextUp(nextUp: List<Song>) {
requireNotNull(queueItem) { "Cannot update next up in non-view state" }.isEnabled =
nextUp.isNotEmpty()
}
}

View file

@ -19,10 +19,10 @@ package org.oxycblt.auxio.playback
import android.content.Context
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -52,32 +52,32 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore
private var pendingDelayedAction: DelayedActionImpl? = null
private val _song = MutableLiveData<Song?>()
private val _song = MutableStateFlow<Song?>(null)
/** The current song. */
val song: LiveData<Song?>
val song: StateFlow<Song?>
get() = _song
private val _parent = MutableLiveData<MusicParent?>()
private val _parent = MutableStateFlow<MusicParent?>(null)
/** The current model that is being played from, such as an [Album] or [Artist] */
val parent: LiveData<MusicParent?>
val parent: StateFlow<MusicParent?>
get() = _parent
private val _isPlaying = MutableLiveData(false)
val isPlaying: LiveData<Boolean>
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean>
get() = _isPlaying
private val _positionSecs = MutableLiveData(0L)
private val _positionSecs = MutableStateFlow(0L)
/** The current playback position, in seconds */
val positionSecs: LiveData<Long>
val positionSecs: StateFlow<Long>
get() = _positionSecs
private val _repeatMode = MutableLiveData(RepeatMode.NONE)
private val _repeatMode = MutableStateFlow(RepeatMode.NONE)
/** The current repeat mode, see [RepeatMode] for more information */
val repeatMode: LiveData<RepeatMode>
val repeatMode: StateFlow<RepeatMode>
get() = _repeatMode
private val _isShuffled = MutableLiveData(false)
val isShuffled: LiveData<Boolean>
private val _isShuffled = MutableStateFlow(false)
val isShuffled: StateFlow<Boolean>
get() = _isShuffled
private val _nextUp = MutableLiveData(listOf<Song>())
private val _nextUp = MutableStateFlow(listOf<Song>())
/** The queue, without the previous items. */
val nextUp: LiveData<List<Song>>
val nextUp: StateFlow<List<Song>>
get() = _nextUp
init {

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.requireAttached
/**
@ -52,7 +53,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
// --- VIEWMODEL SETUP ----
playbackModel.nextUp.observe(viewLifecycleOwner, ::updateQueue)
launch { playbackModel.nextUp.collect(::updateQueue) }
}
override fun onDestroyBinding(binding: FragmentQueueBinding) {

View file

@ -331,8 +331,6 @@ class PlaybackStateManager private constructor() {
logD("State read completed successfully in ${System.currentTimeMillis() - start}ms")
// Get off the IO coroutine since it will cause LiveData updates to throw an exception
if (state != null) {
index = state.index
parent = state.parent

View file

@ -30,6 +30,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.music.Album
@ -49,6 +50,7 @@ import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.requireAttached
/**
@ -107,9 +109,9 @@ class SearchFragment :
// --- VIEWMODEL SETUP ---
searchModel.searchResults.observe(viewLifecycleOwner, ::updateResults)
navModel.exploreNavigationItem.observe(viewLifecycleOwner, ::handleNavigation)
musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse)
launch { searchModel.searchResults.collect(::updateResults) }
launch { musicModel.response.collect(::handleLoaderResponse) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
}
override fun onDestroyBinding(binding: FragmentSearchBinding) {
@ -144,10 +146,6 @@ class SearchFragment :
}
private fun updateResults(results: List<Item>) {
if (isDetached) {
error("Fragment not attached to activity")
}
val binding = requireBinding()
searchAdapter.data.submitList(results.toMutableList()) {

View file

@ -19,11 +19,11 @@ package org.oxycblt.auxio.search
import android.content.Context
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.text.Normalizer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Music
@ -43,16 +43,17 @@ class SearchViewModel : ViewModel() {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val _searchResults = MutableLiveData(listOf<Item>())
private var _filterMode: DisplayMode? = null
private var lastQuery: String? = null
private val _searchResults = MutableStateFlow(listOf<Item>())
/** Current search results from the last [search] call. */
val searchResults: LiveData<List<Item>>
val searchResults: StateFlow<List<Item>>
get() = _searchResults
private var _filterMode: DisplayMode? = null
val filterMode: DisplayMode?
get() = _filterMode
private var lastQuery: String? = null
init {
_filterMode = settingsManager.searchFilterMode
}

View file

@ -28,12 +28,14 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.flow.collect
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAboutBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -61,24 +63,37 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
homeModel.songs.observe(viewLifecycleOwner) { songs ->
binding.aboutSongCount.textSafe = getString(R.string.fmt_songs_loaded, songs.size)
binding.aboutTotalDuration.textSafe =
getString(
R.string.fmt_total_duration,
songs.sumOf { it.durationSecs }.formatDuration(false))
launch {
homeModel.songs.collect { songs ->
binding.aboutSongCount.textSafe = getString(R.string.fmt_songs_loaded, songs.size)
binding.aboutTotalDuration.textSafe =
getString(
R.string.fmt_total_duration,
getString(
R.string.fmt_total_duration,
songs.sumOf { it.durationSecs }.formatDuration(false)))
}
}
homeModel.albums.observe(viewLifecycleOwner) { albums ->
binding.aboutAlbumCount.textSafe = getString(R.string.fmt_albums_loaded, albums.size)
launch {
homeModel.albums.collect { albums ->
binding.aboutAlbumCount.textSafe =
getString(R.string.fmt_albums_loaded, albums.size)
}
}
homeModel.artists.observe(viewLifecycleOwner) { artists ->
binding.aboutArtistCount.textSafe = getString(R.string.fmt_artists_loaded, artists.size)
launch {
homeModel.artists.collect { artists ->
binding.aboutArtistCount.textSafe =
getString(R.string.fmt_artists_loaded, artists.size)
}
}
homeModel.genres.observe(viewLifecycleOwner) { genres ->
binding.aboutGenreCount.textSafe = getString(R.string.fmt_genres_loaded, genres.size)
launch {
homeModel.genres.collect { genres ->
binding.aboutGenreCount.textSafe =
getString(R.string.fmt_genres_loaded, genres.size)
}
}
}

View file

@ -17,9 +17,9 @@
package org.oxycblt.auxio.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Music
/**
@ -27,17 +27,17 @@ import org.oxycblt.auxio.music.Music
* @author OxygenCobalt
*/
class NavigationViewModel : ViewModel() {
private val _mainNavigationAction = MutableLiveData<MainNavigationAction?>()
private val _mainNavigationAction = MutableStateFlow<MainNavigationAction?>(null)
/** Flag for main fragment navigation. Intended for MainFragment use only. */
val mainNavigationAction: LiveData<MainNavigationAction?>
val mainNavigationAction: StateFlow<MainNavigationAction?>
get() = _mainNavigationAction
private val _exploreNavigationItem = MutableLiveData<Music?>()
private val _exploreNavigationItem = MutableStateFlow<Music?>(null)
/**
* Flag for navigation within the explore fragments. Observe this to coordinate navigation to an
* item's UI.
*/
val exploreNavigationItem: LiveData<Music?>
val exploreNavigationItem: StateFlow<Music?>
get() = _exploreNavigationItem
/** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */

View file

@ -31,9 +31,14 @@ import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
/**
@ -147,6 +152,18 @@ val @receiver:ColorRes Int.stateList
/** Require the fragment is attached to an activity. */
fun Fragment.requireAttached() = check(!isDetached) { "Fragment is detached from activity" }
/**
* Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a
* shortcut intended to correctly launch a co-routine on a fragment in a way that won't cause
* miscellaneous coroutine insanity.
*/
fun Fragment.launch(
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend CoroutineScope.() -> Unit
) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) }
}
/**
* Shortcut for querying all items in a database and running [block] with the cursor returned. Will
* not run if the cursor is null.

View file

@ -31,7 +31,6 @@
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<androidx.constraintlayout.widget.ConstraintLayout
@ -40,8 +39,7 @@
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:paddingStart="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium"
tools:visibility="invisible">
android:paddingEnd="@dimen/spacing_medium">
<TextView
android:id="@+id/home_loading_status"
@ -70,6 +68,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/lbl_retry"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_loading_status" />