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:
parent
402a290db7
commit
a65d37c421
32 changed files with 268 additions and 232 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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]. */
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
|
Loading…
Reference in a new issue