diff --git a/CHANGELOG.md b/CHANGELOG.md index 64328c39f..d101a679e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 5a71b3877..234bc465e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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") } } diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 1cc498b27..44ed44e4c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -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() { 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() { // 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() { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 764541284..ae6b85a10 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -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 { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 63a6e96b1..571131eae 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 58daf1340..6c754dc97 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -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() - val currentAlbum: LiveData + private val _currentAlbum = MutableStateFlow(null) + val currentAlbum: StateFlow get() = _currentAlbum - private val _albumData = MutableLiveData(listOf()) - val albumData: LiveData> + private val _albumData = MutableStateFlow(listOf()) + val albumData: StateFlow> get() = _albumData var albumSort: Sort @@ -60,12 +60,12 @@ class DetailViewModel : ViewModel() { currentAlbum.value?.let(::refreshAlbumData) } - private val _currentArtist = MutableLiveData() - val currentArtist: LiveData + private val _currentArtist = MutableStateFlow(null) + val currentArtist: StateFlow get() = _currentArtist - private val _artistData = MutableLiveData(listOf()) - val artistData: LiveData> = _artistData + private val _artistData = MutableStateFlow(listOf()) + val artistData: StateFlow> = _artistData var artistSort: Sort get() = settingsManager.detailArtistSort @@ -74,12 +74,12 @@ class DetailViewModel : ViewModel() { currentArtist.value?.let(::refreshArtistData) } - private val _currentGenre = MutableLiveData() - val currentGenre: LiveData + private val _currentGenre = MutableStateFlow(null) + val currentGenre: StateFlow get() = _currentGenre - private val _genreData = MutableLiveData(listOf()) - val genreData: LiveData> = _genreData + private val _genreData = MutableStateFlow(listOf()) + val genreData: StateFlow> = _genreData var genreSort: Sort get() = settingsManager.detailGenreSort diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 9aa9b8074..11e8a87f9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 353144fea..b4002c946 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -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(), Toolbar.OnMenuI private val homeModel: HomeViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() + private var storagePermissionLauncher: ActivityResultLauncher? = 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(), 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(), 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(), 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(), 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(), 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(), 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(), Toolbar.OnMenuI } } - private fun handleLoaderResponse( - response: MusicStore.Response?, - permLauncher: ActivityResultLauncher - ) { + private fun handleLoaderResponse(response: MusicStore.Response?) { val binding = requireBinding() if (response is MusicStore.Response.Ok) { @@ -296,13 +293,18 @@ class HomeFragment : ViewBindingFragment(), 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) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index b32b9dc47..1c3856aa7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -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()) - val songs: LiveData> + private val _songs = MutableStateFlow(listOf()) + val songs: StateFlow> get() = _songs - private val _albums = MutableLiveData(listOf()) - val albums: LiveData> + private val _albums = MutableStateFlow(listOf()) + val albums: StateFlow> get() = _albums - private val _artists = MutableLiveData(listOf()) - val artists: LiveData> + private val _artists = MutableStateFlow(listOf()) + val artists: MutableStateFlow> get() = _artists - private val _genres = MutableLiveData(listOf()) - val genres: LiveData> + private val _genres = MutableStateFlow(listOf()) + val genres: StateFlow> get() = _genres var tabs: List = visibleTabs @@ -63,18 +63,18 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.Callback private val visibleTabs: List get() = settingsManager.libTabs.filterIsInstance().map { it.mode } - private val _currentTab = MutableLiveData(tabs[0]) - val currentTab: LiveData = _currentTab + private val _currentTab = MutableStateFlow(tabs[0]) + val currentTab: StateFlow = _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 = _shouldRecreateTabs + private val _shouldRecreateTabs = MutableStateFlow(false) + val recreateTabs: StateFlow = _shouldRecreateTabs - private val _isFastScrolling = MutableLiveData(false) - val isFastScrolling: LiveData = _isFastScrolling + private val _isFastScrolling = MutableStateFlow(false) + val isFastScrolling: StateFlow = _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 -> {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index ca136c5d6..09e31f2ae 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -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() { 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? { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 48790b9d3..ebb0df770 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -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() { 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? { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index cca2ba115..4f47c53fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -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() { 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? { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt index e444b6af2..a369f1c6a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt @@ -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 : 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 : FragmentHomeListBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { - setupRecycler(binding.homeRecycler) binding.homeRecycler.popupProvider = this binding.homeRecycler.listener = this } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 443dc3d94..0d5df2b79 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -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() { 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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 658c13042..e283c1d3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index f406d9fc1..90ca071df 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -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, val songs: List ) { - /** 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() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index da42f04ef..dd6b552b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -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(null) - val loaderResponse: LiveData = _loaderResponse + private val _response = MutableStateFlow(null) + val response: StateFlow = _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) - } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt index 6377a351b..8fd8770bf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt @@ -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") } diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt index 3e400cf3c..6dd795762 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt @@ -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()) - val paths: LiveData> + private val _paths = MutableStateFlow(mutableListOf()) + val paths: StateFlow> get() = _paths var isModified: Boolean = false diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt index e3b0b6756..02bae60c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/ExoPlayerBackend.kt @@ -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()) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt index 0f3928597..91d9fa85f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt @@ -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): List { val genres = mutableListOf() - 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 } - - sealed class Event { - object Query : Event() - object LoadSongs : Event() - object BuildLibrary : Event() - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index 555aaa7cb..c41ddd811 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -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() { // -- 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?) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index cd13e6365..6c0738007 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -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) { + requireNotNull(queueItem) { "Cannot update next up in non-view state" }.isEnabled = + nextUp.isNotEmpty() + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index c14ce05ad..47d572fe9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -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() + private val _song = MutableStateFlow(null) /** The current song. */ - val song: LiveData + val song: StateFlow get() = _song - private val _parent = MutableLiveData() + private val _parent = MutableStateFlow(null) /** The current model that is being played from, such as an [Album] or [Artist] */ - val parent: LiveData + val parent: StateFlow get() = _parent - private val _isPlaying = MutableLiveData(false) - val isPlaying: LiveData + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow get() = _isPlaying - private val _positionSecs = MutableLiveData(0L) + private val _positionSecs = MutableStateFlow(0L) /** The current playback position, in seconds */ - val positionSecs: LiveData + val positionSecs: StateFlow 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 + val repeatMode: StateFlow get() = _repeatMode - private val _isShuffled = MutableLiveData(false) - val isShuffled: LiveData + private val _isShuffled = MutableStateFlow(false) + val isShuffled: StateFlow get() = _isShuffled - private val _nextUp = MutableLiveData(listOf()) + private val _nextUp = MutableStateFlow(listOf()) /** The queue, without the previous items. */ - val nextUp: LiveData> + val nextUp: StateFlow> get() = _nextUp init { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index e77844125..3eeaffa47 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -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(), QueueItemList // --- VIEWMODEL SETUP ---- - playbackModel.nextUp.observe(viewLifecycleOwner, ::updateQueue) + launch { playbackModel.nextUp.collect(::updateQueue) } } override fun onDestroyBinding(binding: FragmentQueueBinding) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index c2628d00e..e5cf0a2dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 226b284e5..2c1aec0a5 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -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) { - if (isDetached) { - error("Fragment not attached to activity") - } - val binding = requireBinding() searchAdapter.data.submitList(results.toMutableList()) { diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index dcb78c8fe..cebbc09e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -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()) - private var _filterMode: DisplayMode? = null - private var lastQuery: String? = null - + private val _searchResults = MutableStateFlow(listOf()) /** Current search results from the last [search] call. */ - val searchResults: LiveData> + val searchResults: StateFlow> get() = _searchResults + + private var _filterMode: DisplayMode? = null val filterMode: DisplayMode? get() = _filterMode + private var lastQuery: String? = null + init { _filterMode = settingsManager.searchFilterMode } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index bd889b6cf..ee404e42f 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -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() { 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) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt index c42b62fb8..ba919e3bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt @@ -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() + private val _mainNavigationAction = MutableStateFlow(null) /** Flag for main fragment navigation. Intended for MainFragment use only. */ - val mainNavigationAction: LiveData + val mainNavigationAction: StateFlow get() = _mainNavigationAction - private val _exploreNavigationItem = MutableLiveData() + private val _exploreNavigationItem = MutableStateFlow(null) /** * Flag for navigation within the explore fragments. Observe this to coordinate navigation to an * item's UI. */ - val exploreNavigationItem: LiveData + val exploreNavigationItem: StateFlow get() = _exploreNavigationItem /** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */ diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index 01e2a8ad3..a0a841078 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -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. diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index e616c5e73..ad22c2bdf 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -31,7 +31,6 @@ + android:paddingEnd="@dimen/spacing_medium">