mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2026-10-22 23:42:14 +03:00
Merge branch 'cleanviewmodelboundary' into 'develop'
Clean MVC boundary transgressions See merge request ultrasonic/ultrasonic!1441
This commit is contained in:
commit
2d49ea0435
9 files changed
+250
-136
No files matched your search
@@ -70,7 +70,7 @@ val appPermanentStorage = module {
|
||||
viewModel { GenreListModel(get(), get(), get(), get()) }
|
||||
viewModel { PlaylistListModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { PodcastListModel(get(), get(), get(), get()) }
|
||||
viewModel { SearchListModel(get(), get(), get(), get()) }
|
||||
viewModel { SearchListModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { ShareListModel(get(), get(), get(), get()) }
|
||||
viewModel { TrackCollectionModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { DownloadListModel(get(), get(), get(), get(), get()) }
|
||||
|
||||
@@ -12,13 +12,11 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
@@ -26,26 +24,29 @@ import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.AlbumGridDelegate
|
||||
import org.moire.ultrasonic.adapters.AlbumRowDelegate
|
||||
import org.moire.ultrasonic.adapters.PopupMenuFactory
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.model.AlbumListModel
|
||||
import org.moire.ultrasonic.util.LayoutType
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.FilterButtonBar
|
||||
import org.moire.ultrasonic.view.SortOrder
|
||||
import org.moire.ultrasonic.view.ViewCapabilities
|
||||
|
||||
/**
|
||||
* Displays a list of Albums from the media library
|
||||
*
|
||||
* Initial layout and sort order are passed through the fragment's arguments, never through
|
||||
* constructor parameters: OS-driven recreation instantiates fragments with the default
|
||||
* constructor, and only arguments survive that.
|
||||
*/
|
||||
class AlbumListFragment(
|
||||
private var layoutType: LayoutType = LayoutType.LIST,
|
||||
private var orderType: SortOrder? = null
|
||||
) : EntryListFragment<Album>(),
|
||||
class AlbumListFragment :
|
||||
EntryListFragment<Album>(),
|
||||
FilterableFragment {
|
||||
|
||||
private var layoutType: LayoutType = LayoutType.LIST
|
||||
private var orderType: SortOrder? = null
|
||||
|
||||
private val popupMenuFactory: PopupMenuFactory by inject()
|
||||
|
||||
private var filterButtonBar: FilterButtonBar? = null
|
||||
@@ -83,57 +84,21 @@ class AlbumListFragment(
|
||||
append: Boolean = navArgs.append,
|
||||
newSortOrderChosen: Boolean = false
|
||||
) {
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
listModel.withDelayedLoadingIndicator {
|
||||
if (navArgs.byArtist) {
|
||||
listModel.getAlbumsOfArtist(
|
||||
id = navArgs.id!!,
|
||||
name = navArgs.title
|
||||
)
|
||||
} else if (orderType == SortOrder.BY_GENRE) {
|
||||
fetchAlbumsByGenre(refresh, append, newSortOrderChosen)
|
||||
} else {
|
||||
listModel.getAlbums(
|
||||
albumListType = orderType?.mapToAlbumListType() ?: navArgs.type,
|
||||
size = navArgs.size,
|
||||
offset = navArgs.offset,
|
||||
append = append,
|
||||
refresh = refresh or append
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAlbumsByGenre(
|
||||
refresh: Boolean,
|
||||
append: Boolean,
|
||||
newSortOrderChosen: Boolean
|
||||
) {
|
||||
if (selectedGenre != null && !newSortOrderChosen) {
|
||||
listModel.getAlbums(
|
||||
albumListType = AlbumListType.BY_GENRE,
|
||||
size = navArgs.size,
|
||||
offset = navArgs.offset,
|
||||
append = append,
|
||||
refresh = refresh or append,
|
||||
genre = selectedGenre
|
||||
)
|
||||
swipeRefresh?.isRefreshing = false
|
||||
return
|
||||
}
|
||||
val genres = listModel.getGenres(true)
|
||||
if (genres.isEmpty()) {
|
||||
swipeRefresh?.isRefreshing = false
|
||||
return
|
||||
}
|
||||
val genreStrings = genres.map { it.name }.toTypedArray()
|
||||
if (childFragmentManager.findFragmentByTag(ItemSelectionDialogFragment.TAG) == null) {
|
||||
ItemSelectionDialogFragment.create(R.string.main_genres_title, genreStrings)
|
||||
.show(childFragmentManager, ItemSelectionDialogFragment.TAG)
|
||||
}
|
||||
// All branching and coroutine handling lives in the model; the fragment only
|
||||
// forwards its state.
|
||||
listModel.fetchAlbums(
|
||||
byArtist = navArgs.byArtist,
|
||||
artistId = navArgs.id,
|
||||
artistName = navArgs.title,
|
||||
orderType = orderType,
|
||||
fallbackListType = navArgs.type,
|
||||
size = navArgs.size,
|
||||
offset = navArgs.offset,
|
||||
refresh = refresh,
|
||||
append = append,
|
||||
selectedGenre = selectedGenre,
|
||||
newSortOrderChosen = newSortOrderChosen
|
||||
)
|
||||
}
|
||||
|
||||
override fun setLayoutType(newType: LayoutType) {
|
||||
@@ -161,6 +126,10 @@ class AlbumListFragment(
|
||||
override fun setOrderType(newOrder: SortOrder) {
|
||||
orderType = newOrder
|
||||
|
||||
// Persist to the arguments as well: instance state alone is lost on process death,
|
||||
// and Settings is only written for the main Albums tab.
|
||||
arguments?.putString(ARG_ORDER_TYPE, newOrder.name)
|
||||
|
||||
// Only persist the sort order for the main Albums tab (not artist-specific views)
|
||||
if (!navArgs.byArtist) {
|
||||
Settings.lastAlbumSortOrder = newOrder.name
|
||||
@@ -168,7 +137,7 @@ class AlbumListFragment(
|
||||
|
||||
// If we are on an Artist page we just need to reorder the list. Otherwise refetch
|
||||
if (navArgs.byArtist) {
|
||||
listModel.sortListByOrder(newOrder.mapToAlbumListType())
|
||||
listModel.sortListByOrder(newOrder)
|
||||
} else {
|
||||
fetchAlbums(refresh = true, append = false, newSortOrderChosen = true)
|
||||
}
|
||||
@@ -222,12 +191,16 @@ class AlbumListFragment(
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (savedInstanceState != null) {
|
||||
val orderTypeName = savedInstanceState.getString("order_type")
|
||||
if (orderTypeName != null) {
|
||||
orderType = SortOrder.valueOf(orderTypeName)
|
||||
}
|
||||
selectedGenre = savedInstanceState.getString("selected_genre")
|
||||
|
||||
// Initial state comes from the arguments (written by the creator of the fragment
|
||||
// and preserved by the OS across recreation), then the instance state wins if present.
|
||||
arguments?.let { args ->
|
||||
args.getString(ARG_LAYOUT_TYPE)?.let { layoutType = LayoutType.valueOf(it) }
|
||||
args.getString(ARG_ORDER_TYPE)?.let { orderType = SortOrder.fromName(it) }
|
||||
}
|
||||
savedInstanceState?.let { state ->
|
||||
state.getString(ARG_ORDER_TYPE)?.let { orderType = SortOrder.fromName(it) }
|
||||
selectedGenre = state.getString(ARG_SELECTED_GENRE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +233,18 @@ class AlbumListFragment(
|
||||
}
|
||||
}
|
||||
|
||||
// The model requests the genre selection dialog whenever a fetch needs the user
|
||||
// to pick a genre ("By Genre" in the orderType dropdown menu).
|
||||
listModel.genreSelectionRequest.observe(viewLifecycleOwner) { genres ->
|
||||
if (genres.isEmpty()) return@observe
|
||||
if (childFragmentManager.findFragmentByTag(ItemSelectionDialogFragment.TAG) == null) {
|
||||
ItemSelectionDialogFragment.create(
|
||||
R.string.main_genres_title,
|
||||
genres.toTypedArray()
|
||||
).show(childFragmentManager, ItemSelectionDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup refresh handler
|
||||
swipeRefresh = view.findViewById(refreshListId)
|
||||
swipeRefresh?.setOnRefreshListener {
|
||||
@@ -308,8 +293,8 @@ class AlbumListFragment(
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString("order_type", orderType?.name)
|
||||
outState.putString("selected_genre", selectedGenre)
|
||||
outState.putString(ARG_ORDER_TYPE, orderType?.name)
|
||||
outState.putString(ARG_SELECTED_GENRE, selectedGenre)
|
||||
}
|
||||
|
||||
private fun setupFilterBar(view: View) {
|
||||
@@ -343,21 +328,15 @@ class AlbumListFragment(
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
|
||||
private fun SortOrder.mapToAlbumListType(): AlbumListType = when (this) {
|
||||
SortOrder.RANDOM -> AlbumListType.RANDOM
|
||||
SortOrder.NEWEST -> AlbumListType.NEWEST
|
||||
SortOrder.HIGHEST -> AlbumListType.HIGHEST
|
||||
SortOrder.FREQUENT -> AlbumListType.FREQUENT
|
||||
SortOrder.RECENT -> AlbumListType.RECENT
|
||||
SortOrder.BY_NAME -> AlbumListType.SORTED_BY_NAME
|
||||
SortOrder.BY_ARTIST -> AlbumListType.SORTED_BY_ARTIST
|
||||
SortOrder.BY_GENRE -> AlbumListType.BY_GENRE
|
||||
SortOrder.STARRED -> AlbumListType.STARRED
|
||||
SortOrder.BY_YEAR -> AlbumListType.BY_YEAR
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ROWS = 3
|
||||
private const val ALBUM_VISIBLE_THRESHOLD = 20
|
||||
|
||||
/** Key for the initial [LayoutType], stored in the arguments by the creator. */
|
||||
const val ARG_LAYOUT_TYPE = "album_list_layout_type"
|
||||
|
||||
/** Key for the sort order override, stored in the arguments and instance state. */
|
||||
const val ARG_ORDER_TYPE = "album_list_order_type"
|
||||
private const val ARG_SELECTED_GENRE = "album_list_selected_genre"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.BaseAdapter
|
||||
@@ -19,7 +18,6 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.service.QueueInsertionMode
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
|
||||
/**
|
||||
* Lists the Bookmarks available on the server
|
||||
@@ -42,12 +40,8 @@ class BookmarksFragment : TrackCollectionFragment() {
|
||||
refresh: Boolean,
|
||||
append: Boolean
|
||||
): LiveData<List<MusicDirectory.Child>> {
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
listModel.launchWithLoadingIndicator {
|
||||
listModel.getBookmarks()
|
||||
swipeRefresh?.isRefreshing = false
|
||||
}
|
||||
return listModel.currentList
|
||||
}
|
||||
|
||||
@@ -193,27 +193,18 @@ class MusicCollectionAdapter(fragment: Fragment, initialType: LayoutType = Layou
|
||||
|
||||
val fragment = when (position) {
|
||||
0 -> ArtistListFragment()
|
||||
|
||||
1 -> {
|
||||
val savedOrder = Settings.lastAlbumSortOrder
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { SortOrder.fromName(it) }
|
||||
AlbumListFragment(layoutType, savedOrder)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
val savedOrder = Settings.lastSongsSortOrder
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { SortOrder.fromName(it) }
|
||||
?: SortOrder.RANDOM
|
||||
TrackCollectionFragment(savedOrder)
|
||||
}
|
||||
|
||||
1 -> AlbumListFragment()
|
||||
2 -> TrackCollectionFragment()
|
||||
else -> SelectGenreFragment()
|
||||
}
|
||||
|
||||
// Initial state (layout type / sort order overrides) is passed through the arguments,
|
||||
// not constructor parameters: OS-driven recreation instantiates fragments with the
|
||||
// default constructor, and only the arguments survive that.
|
||||
val args = action.arguments
|
||||
applyInitialFragmentState(position, args)
|
||||
fragment.arguments = args
|
||||
fragmentMap[position] = SoftReference(fragment)
|
||||
fragment.arguments = action.arguments
|
||||
|
||||
// See comment in onPageSelected
|
||||
if (propagateCapabilitiesMatcher == position) {
|
||||
@@ -232,6 +223,24 @@ class MusicCollectionAdapter(fragment: Fragment, initialType: LayoutType = Layou
|
||||
3 -> context.getString(R.string.main_genres_title)
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun applyInitialFragmentState(position: Int, args: Bundle) {
|
||||
when (position) {
|
||||
1 -> {
|
||||
args.putString(AlbumListFragment.ARG_LAYOUT_TYPE, layoutType.name)
|
||||
Settings.lastAlbumSortOrder
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { args.putString(AlbumListFragment.ARG_ORDER_TYPE, it) }
|
||||
}
|
||||
|
||||
2 -> {
|
||||
val savedOrder = Settings.lastSongsSortOrder
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: SortOrder.RANDOM.name
|
||||
args.putString(TrackCollectionFragment.ARG_SORT_ORDER, savedOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FilterableFragment {
|
||||
|
||||
@@ -11,11 +11,9 @@ import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.R
|
||||
@@ -37,14 +35,12 @@ import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
|
||||
import org.moire.ultrasonic.model.SearchListModel
|
||||
import org.moire.ultrasonic.service.DownloadManager
|
||||
import org.moire.ultrasonic.service.PlaybackQueueActions
|
||||
import org.moire.ultrasonic.service.PlaybackRepository
|
||||
import org.moire.ultrasonic.subsonic.VideoPlayer
|
||||
import org.moire.ultrasonic.util.ContextMenuUtil
|
||||
import org.moire.ultrasonic.util.RefreshableFragment
|
||||
import org.moire.ultrasonic.util.UiUtil
|
||||
import org.moire.ultrasonic.util.UiUtil.toast
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
|
||||
/**
|
||||
* Initiates a search on the media library and displays the results
|
||||
@@ -55,7 +51,6 @@ class SearchFragment :
|
||||
RefreshableFragment {
|
||||
private var searchResult: SearchResult? = null
|
||||
override var swipeRefresh: SwipeRefreshLayout? = null
|
||||
private val playbackQueue: PlaybackQueueActions by inject()
|
||||
private val playback: PlaybackRepository by inject()
|
||||
private val downloadManager: DownloadManager by inject()
|
||||
private val popupMenuFactory: PopupMenuFactory by inject()
|
||||
@@ -142,12 +137,8 @@ class SearchFragment :
|
||||
}
|
||||
|
||||
private fun search(query: String, autoplay: Boolean) {
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
listModel.launchWithLoadingIndicator {
|
||||
val result = listModel.search(query)
|
||||
swipeRefresh?.isRefreshing = false
|
||||
if (result != null && autoplay) {
|
||||
autoplay()
|
||||
}
|
||||
@@ -235,9 +226,7 @@ class SearchFragment :
|
||||
}
|
||||
|
||||
private fun onSongSelected(song: Track, append: Boolean) {
|
||||
listModel.viewModelScope.launch {
|
||||
playbackQueue.playAppended(song, replaceQueue = !append)
|
||||
}
|
||||
listModel.playSong(song, append)
|
||||
toast(resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1))
|
||||
}
|
||||
|
||||
|
||||
+25
-14
@@ -18,7 +18,6 @@ import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -57,7 +56,6 @@ import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.UiUtil.navigateToCurrent
|
||||
import org.moire.ultrasonic.util.UiUtil.toast
|
||||
import org.moire.ultrasonic.util.toastingExceptionHandler
|
||||
import org.moire.ultrasonic.view.SortOrder
|
||||
import org.moire.ultrasonic.view.ViewCapabilities
|
||||
import timber.log.Timber
|
||||
@@ -68,9 +66,13 @@ import timber.log.Timber
|
||||
* In most cases the data should be just a list of Entries, but there are some cases
|
||||
* where the list can contain Albums as well. This happens especially when having ID3 tags disabled,
|
||||
* or using Offline mode, both in which Indexes instead of Artists are being used.
|
||||
*
|
||||
* The initial sort order is passed through the fragment's arguments, never through a
|
||||
* constructor parameter: OS-driven recreation instantiates fragments with the default
|
||||
* constructor, and only arguments survive that.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
open class TrackCollectionFragment :
|
||||
MultiListFragment<MusicDirectory.Child>(),
|
||||
FilterableFragment {
|
||||
|
||||
@@ -99,7 +101,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
|
||||
override val listModel: TrackCollectionModel by viewModel()
|
||||
|
||||
private var sortOrder = initialOrder
|
||||
private var sortOrder: SortOrder? = null
|
||||
|
||||
/**
|
||||
* The id of the main layout
|
||||
@@ -110,8 +112,11 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val sortOrderName = savedInstanceState?.getString("sort_order")
|
||||
sortOrderName?.let { sortOrder = SortOrder.valueOf(it) }
|
||||
|
||||
// Initial state comes from the arguments (written by the creator of the fragment
|
||||
// and preserved by the OS across recreation), then the instance state wins if present.
|
||||
arguments?.getString(ARG_SORT_ORDER)?.let { sortOrder = SortOrder.fromName(it) }
|
||||
savedInstanceState?.getString(ARG_SORT_ORDER)?.let { sortOrder = SortOrder.fromName(it) }
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@@ -206,7 +211,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString("sort_order", sortOrder?.name)
|
||||
outState.putString(ARG_SORT_ORDER, sortOrder?.name)
|
||||
}
|
||||
|
||||
private fun loadMoreTracks() {
|
||||
@@ -569,11 +574,9 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
val refresh2 = navArgs.refresh || refresh
|
||||
|
||||
if (refresh || append || listModel.currentList.value == null) {
|
||||
listModel.viewModelScope.launch(
|
||||
toastingExceptionHandler()
|
||||
) {
|
||||
swipeRefresh?.isRefreshing = true
|
||||
|
||||
// The whole branching and the coroutine itself live in the model; the fragment
|
||||
// only forwards its state and updates the title along the way.
|
||||
listModel.launchWithLoadingIndicator {
|
||||
if (playlistId != null) {
|
||||
setTitle(playlistName!!)
|
||||
listModel.getPlaylist(playlistId, playlistName)
|
||||
@@ -606,8 +609,6 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
listModel.getMusicDirectory(refresh2, id, name)
|
||||
}
|
||||
}
|
||||
|
||||
swipeRefresh?.isRefreshing = false
|
||||
}
|
||||
}
|
||||
return listModel.currentList
|
||||
@@ -686,6 +687,11 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
|
||||
override fun setOrderType(newOrder: SortOrder) {
|
||||
sortOrder = newOrder
|
||||
|
||||
// Persist to the arguments as well: instance state alone is lost on process death,
|
||||
// and Settings is only written for the main Songs tab.
|
||||
arguments?.putString(ARG_SORT_ORDER, newOrder.name)
|
||||
|
||||
val isSongsTab = navArgs.playlistId == null &&
|
||||
navArgs.podcastChannelId == null &&
|
||||
navArgs.shareId == null
|
||||
@@ -709,4 +715,9 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Key for the sort order override, stored in the arguments and instance state. */
|
||||
const val ARG_SORT_ORDER = "track_collection_sort_order"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.moire.ultrasonic.service.CachedMusicService
|
||||
import org.moire.ultrasonic.service.MusicService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.view.SortOrder
|
||||
|
||||
class AlbumListModel(
|
||||
application: Application,
|
||||
@@ -28,9 +29,83 @@ class AlbumListModel(
|
||||
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
|
||||
|
||||
val list: MutableLiveData<List<Album>> = MutableLiveData()
|
||||
|
||||
/**
|
||||
* Set when a load requires the user to pick a genre; the observing fragment
|
||||
* displays the selection dialog. Empty means no selection is needed.
|
||||
*/
|
||||
val genreSelectionRequest: MutableLiveData<List<String>> = MutableLiveData()
|
||||
|
||||
private var lastType: AlbumListType? = null
|
||||
private var loadedUntil: Int = 0
|
||||
|
||||
/**
|
||||
* Central entry point for loading the album list: launches on the ViewModel scope and
|
||||
* owns the branching between albums of an artist, genre selection and plain album lists.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun fetchAlbums(
|
||||
byArtist: Boolean,
|
||||
artistId: String?,
|
||||
artistName: String?,
|
||||
orderType: SortOrder?,
|
||||
fallbackListType: AlbumListType,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
refresh: Boolean,
|
||||
append: Boolean,
|
||||
selectedGenre: String?,
|
||||
newSortOrderChosen: Boolean = false
|
||||
) {
|
||||
launchWithLoadingIndicator {
|
||||
if (byArtist) {
|
||||
getAlbumsOfArtist(artistId!!, artistName)
|
||||
} else if (orderType == SortOrder.BY_GENRE) {
|
||||
fetchAlbumsByGenre(
|
||||
refresh = refresh,
|
||||
append = append,
|
||||
size = size,
|
||||
offset = offset,
|
||||
selectedGenre = selectedGenre,
|
||||
newSortOrderChosen = newSortOrderChosen
|
||||
)
|
||||
} else {
|
||||
getAlbums(
|
||||
albumListType = orderType?.mapToAlbumListType() ?: fallbackListType,
|
||||
size = size,
|
||||
offset = offset,
|
||||
append = append,
|
||||
refresh = refresh or append
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun fetchAlbumsByGenre(
|
||||
refresh: Boolean,
|
||||
append: Boolean,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
selectedGenre: String?,
|
||||
newSortOrderChosen: Boolean
|
||||
) {
|
||||
if (selectedGenre != null && !newSortOrderChosen) {
|
||||
getAlbums(
|
||||
albumListType = AlbumListType.BY_GENRE,
|
||||
size = size,
|
||||
offset = offset,
|
||||
append = append,
|
||||
refresh = refresh or append,
|
||||
genre = selectedGenre
|
||||
)
|
||||
return
|
||||
}
|
||||
val genres = getGenres(true)
|
||||
if (genres.isEmpty()) return
|
||||
genreSelectionRequest.postValue(genres.map { it.name })
|
||||
}
|
||||
|
||||
suspend fun getAlbumsOfArtist(id: String, name: String?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = musicServiceFactory.getMusicService()
|
||||
@@ -157,8 +232,8 @@ class AlbumListModel(
|
||||
musicService.getGenres(refresh)
|
||||
}
|
||||
|
||||
fun sortListByOrder(order: AlbumListType) {
|
||||
val newList = when (order) {
|
||||
fun sortListByOrder(order: SortOrder) {
|
||||
val newList = when (order.mapToAlbumListType()) {
|
||||
AlbumListType.BY_YEAR -> {
|
||||
list.value?.sortedBy {
|
||||
it.year
|
||||
@@ -193,3 +268,20 @@ class AlbumListModel(
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the user-facing sort order to the Subsonic album list type.
|
||||
* Lives with the model, which is the sole consumer of the mapping.
|
||||
*/
|
||||
private fun SortOrder.mapToAlbumListType(): AlbumListType = when (this) {
|
||||
SortOrder.RANDOM -> AlbumListType.RANDOM
|
||||
SortOrder.NEWEST -> AlbumListType.NEWEST
|
||||
SortOrder.HIGHEST -> AlbumListType.HIGHEST
|
||||
SortOrder.FREQUENT -> AlbumListType.FREQUENT
|
||||
SortOrder.RECENT -> AlbumListType.RECENT
|
||||
SortOrder.BY_NAME -> AlbumListType.SORTED_BY_NAME
|
||||
SortOrder.BY_ARTIST -> AlbumListType.SORTED_BY_ARTIST
|
||||
SortOrder.BY_GENRE -> AlbumListType.BY_GENRE
|
||||
SortOrder.STARRED -> AlbumListType.STARRED
|
||||
SortOrder.BY_YEAR -> AlbumListType.BY_YEAR
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -55,6 +56,32 @@ open class GenericListModel(
|
||||
|
||||
open fun showSelectFolderHeader(): Boolean = false
|
||||
|
||||
/**
|
||||
* Unified error handling for coroutines launched by the models. Reports through
|
||||
* [CommunicationError] and toasts the user.
|
||||
*/
|
||||
private val errorHandler = CoroutineExceptionHandler { _, exception ->
|
||||
communicationError.handleError(exception, context, showToast = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a one-shot operation on the ViewModel's scope with error handling.
|
||||
* Fragments must not touch viewModelScope directly; they call this (or a dedicated
|
||||
* model method) so coroutine lifetime stays owned by - and testable with - the ViewModel.
|
||||
*/
|
||||
fun launchWithErrorHandler(block: suspend () -> Unit): Job =
|
||||
viewModelScope.launch(errorHandler) {
|
||||
block()
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches [block] with error handling and the delayed loading indicator, so the
|
||||
* pull-to-refresh state of the observing fragment is driven through [isRefreshing].
|
||||
*/
|
||||
fun launchWithLoadingIndicator(block: suspend () -> Unit): Job = launchWithErrorHandler {
|
||||
withDelayedLoadingIndicator { block() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check online status
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,9 @@ import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.SearchCriteria
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.PlaybackQueueActions
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
||||
@@ -22,7 +24,8 @@ class SearchListModel(
|
||||
application: Application,
|
||||
activeServerProvider: ActiveServerProvider,
|
||||
musicServiceFactory: MusicServiceFactory,
|
||||
communicationError: CommunicationError
|
||||
communicationError: CommunicationError,
|
||||
private val playbackQueue: PlaybackQueueActions
|
||||
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
|
||||
|
||||
var searchResult: MutableLiveData<SearchResult?> = MutableLiveData()
|
||||
@@ -42,6 +45,16 @@ class SearchListModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a single song for playback. Runs on the ViewModel's scope so the
|
||||
* enqueue outlives the fragment that triggered it.
|
||||
*/
|
||||
fun playSong(song: Track, append: Boolean) {
|
||||
launchWithErrorHandler {
|
||||
playbackQueue.playAppended(song, replaceQueue = !append)
|
||||
}
|
||||
}
|
||||
|
||||
fun trimResultLength(
|
||||
result: SearchResult,
|
||||
maxArtists: Int = Settings.defaultArtists,
|
||||
|
||||
Reference in new issue
Block a user