diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 0bd0b7cb..a88dd182 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -45,6 +45,8 @@ complexity: thresholdInObjects: 30 LabeledExpression: active: false + LongParameterList: + ignoreDefaultParameters: true style: active: true diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt index 7c01e812..313d696a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt @@ -65,4 +65,16 @@ interface AlbumDao : GenericDao { */ @Query("DELETE FROM albums WHERE id LIKE :id") fun delete(id: String) + + /** + * Get albums by genre + */ + @Query("SELECT * FROM albums WHERE genre LIKE :id ORDER BY title ASC LIMIT :offset,:size") + fun byGenre(id: String, size: Int, offset: Int = 0): List + + /** + * Get list of genres from albums. + */ + @Query("SELECT DISTINCT genre FROM albums") + fun getGenres(): List } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt index 9161e43f..19ce1bf5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -63,6 +63,8 @@ class AlbumListFragment( private val navArgs: AlbumListFragmentArgs by navArgs() + private var selectedGenre: String? = null + /** * The central function to pass a query to the model and return a LiveData object */ @@ -72,7 +74,11 @@ class AlbumListFragment( return listModel.list } - private fun fetchAlbums(refresh: Boolean = navArgs.refresh, append: Boolean = navArgs.append) { + private fun fetchAlbums( + refresh: Boolean = navArgs.refresh, + append: Boolean = navArgs.append, + newSortOrderChosen: Boolean = false + ) { listModel.viewModelScope.launch( toastingExceptionHandler() ) { @@ -84,6 +90,8 @@ class AlbumListFragment( id = navArgs.id!!, name = navArgs.title ) + } else if (orderType == SortOrder.BY_GENRE) { + fetchAlbumsByGenre(refresh, append, newSortOrderChosen) } else { listModel.getAlbums( albumListType = orderType?.mapToAlbumListType() ?: navArgs.type, @@ -97,6 +105,35 @@ class AlbumListFragment( } } + 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) + } + } + override fun setLayoutType(newType: LayoutType) { layoutType = newType viewManager = if (layoutType == LayoutType.LIST) { @@ -126,7 +163,7 @@ class AlbumListFragment( if (navArgs.byArtist) { listModel.sortListByOrder(newOrder.mapToAlbumListType()) } else { - fetchAlbums(refresh = true, append = false) + fetchAlbums(refresh = true, append = false, newSortOrderChosen = true) } } @@ -135,6 +172,7 @@ class AlbumListFragment( supportedSortOrders = getListOfSortOrders() ) + @Suppress("ComplexMethod") private fun getListOfSortOrders(): List { val useId3 = Settings.id3TagsEnabledOnline val useId3Offline = Settings.id3TagsEnabledOffline @@ -166,10 +204,24 @@ class AlbumListFragment( if (isOnline || useId3Offline) { supported.add(SortOrder.BY_ARTIST) } + if (isOnline || useId3Offline) { + supported.add(SortOrder.BY_GENRE) + } return supported } + 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") + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -182,6 +234,23 @@ class AlbumListFragment( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // Handler for genre selection dialog. Invoked if the user selects "By Genre" in the + // orderType dropdown menu. + childFragmentManager.setFragmentResultListener( + ItemSelectionDialogFragment.REQUEST_KEY, + viewLifecycleOwner + ) { _, bundle -> + if (bundle.getBoolean(ItemSelectionDialogFragment.RESULT_CANCELLED)) { + swipeRefresh?.isRefreshing = false + return@setFragmentResultListener + } + val genreName = bundle.getString(ItemSelectionDialogFragment.RESULT_SELECTED_ITEM) + if (genreName != null) { + selectedGenre = genreName + fetchAlbums(refresh = true, append = false) + } + } + // Setup refresh handler swipeRefresh = view.findViewById(refreshListId) swipeRefresh?.setOnRefreshListener { @@ -218,6 +287,12 @@ class AlbumListFragment( emptyTextView.setText(R.string.select_album_empty) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString("order_type", orderType?.name) + outState.putString("selected_genre", selectedGenre) + } + private fun setupFilterBar(view: View) { // Load last layout from settings layoutType = LayoutType.from(Settings.lastViewType) @@ -256,6 +331,7 @@ class AlbumListFragment( 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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ItemSelectionDialogFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ItemSelectionDialogFragment.kt new file mode 100644 index 00000000..7f8f6ba8 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ItemSelectionDialogFragment.kt @@ -0,0 +1,59 @@ +/* + * ItemSelectionDialogFragment.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.fragment + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.moire.ultrasonic.R + +// Displays a list of items to select from in a dialog. Returns either RESULT_CANCELLED=true or +// RESULT_SELECTED_ITEM="foo" as a string. +class ItemSelectionDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = arguments?.getInt(ARGS_TITLE) ?: R.string.common_title + val items = arguments?.getStringArray(ARGS_ITEMS) ?: emptyArray() + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleId) + .setItems(items) { _, which -> + val result = Bundle() + result.putString(RESULT_SELECTED_ITEM, items[which]) + setFragmentResult(REQUEST_KEY, result) + } + .create() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + val result = Bundle() + result.putBoolean(RESULT_CANCELLED, true) + setFragmentResult(REQUEST_KEY, result) + } + + companion object { + const val TAG = "ItemSelectionDialog" + const val REQUEST_KEY = "ItemSelectionRequest" + const val RESULT_SELECTED_ITEM = "SelectedItem" + const val RESULT_CANCELLED = "Cancelled" + const val ARGS_TITLE = "Title" + const val ARGS_ITEMS = "Items" + + fun create(@StringRes titleId: Int, items: Array): ItemSelectionDialogFragment { + val frag = ItemSelectionDialogFragment() + val args = Bundle() + args.putInt(ARGS_TITLE, titleId) + args.putStringArray(ARGS_ITEMS, items) + frag.setArguments(args) + return frag + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt index 018390a3..671ce75d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.withContext import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Album +import org.moire.ultrasonic.domain.Genre import org.moire.ultrasonic.service.MusicServiceFactory class AlbumListModel(application: Application) : GenericListModel(application) { @@ -35,7 +36,8 @@ class AlbumListModel(application: Application) : GenericListModel(application) { size: Int = 0, offset: Int = 0, append: Boolean = false, - refresh: Boolean + refresh: Boolean, + genre: String? = null ) { // Don't reload the data if navigating back to the view that was active before. // This way, we keep the scroll position @@ -70,6 +72,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) { albumListType, size, offset, + genre, musicFolderId ) } else { @@ -96,6 +99,11 @@ class AlbumListModel(application: Application) : GenericListModel(application) { } } + suspend fun getGenres(refresh: Boolean): List = withContext(Dispatchers.IO) { + val musicService = MusicServiceFactory.getMusicService() + musicService.getGenres(refresh) + } + fun sortListByOrder(order: AlbumListType) { val newList = when (order) { AlbumListType.BY_YEAR -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt index 5acb97b4..c9fb7428 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt @@ -280,8 +280,9 @@ class CachedMusicService(private val musicService: MusicService) : type: AlbumListType, size: Int, offset: Int, + genre: String?, musicFolderId: String? - ): List = musicService.getAlbumList2(type, size, offset, musicFolderId) + ): List = musicService.getAlbumList2(type, size, offset, genre, musicFolderId) @Throws(Exception::class) override fun getRandomSongs(size: Int): MusicDirectory = musicService.getRandomSongs(size) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt index ed0c195b..d5684d72 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt @@ -1050,6 +1050,7 @@ class MediaLibrarySessionCallback : type, DISPLAY_LIMIT, offset, + null, null ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt index 61ef903b..08e11780 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -107,6 +107,7 @@ interface MusicService { type: AlbumListType, size: Int, offset: Int, + genre: String? = null, musicFolderId: String? ): List diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index 7e32682f..efe57565 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -315,12 +315,14 @@ class OfflineMusicService : type: AlbumListType, size: Int, offset: Int, + genre: String?, musicFolderId: String? ): List { // TODO: Implement filtering by musicFolder? return when (type) { AlbumListType.NEWEST -> cachedAlbums.orderedByAge(size, offset) AlbumListType.SORTED_BY_ARTIST -> cachedAlbums.orderedByArtist(size, offset) + AlbumListType.BY_GENRE -> cachedAlbums.byGenre(genre!!, size, offset) else -> cachedAlbums.orderedByName(size, offset) } } @@ -364,8 +366,10 @@ class OfflineMusicService : throw OfflineException("Getting Songs By Genre not available in offline mode") @Throws(Exception::class) - override fun getGenres(refresh: Boolean): List = - throw OfflineException("Getting Genres not available in offline mode") + override fun getGenres(refresh: Boolean): List { + val genres = cachedAlbums.getGenres() + return genres.map { Genre(it, it) } + } @Throws(Exception::class) override fun getUser(username: String): UserInfo = diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt index 8ced3d9e..bf4975ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt @@ -320,6 +320,7 @@ open class RESTMusicService( type: AlbumListType, size: Int, offset: Int, + genre: String?, musicFolderId: String? ): List { val response = API.getAlbumList2( @@ -328,7 +329,7 @@ open class RESTMusicService( offset, null, null, - null, + genre, musicFolderId ).execute().throwOnFailure() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/FilterButtonBar.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/FilterButtonBar.kt index 3bac0d3b..d01722d6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/FilterButtonBar.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/FilterButtonBar.kt @@ -182,6 +182,7 @@ class FilterButtonBar : ConstraintLayout { SortOrder.RECENT -> R.string.main_albums_recent SortOrder.BY_NAME -> R.string.main_albums_alphaByName SortOrder.BY_ARTIST -> R.string.main_albums_alphaByArtist + SortOrder.BY_GENRE -> R.string.main_albums_byGenre SortOrder.STARRED -> R.string.main_albums_starred SortOrder.BY_YEAR -> R.string.main_albums_by_year } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SortOrder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SortOrder.kt index e646f152..6d9c19dc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SortOrder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SortOrder.kt @@ -18,6 +18,7 @@ enum class SortOrder(val typeName: String) { RECENT("recent"), BY_NAME("alphabeticalByName"), BY_ARTIST("alphabeticalByArtist"), + BY_GENRE("byGenre"), STARRED("starred"), BY_YEAR("byYear") } diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index abf4bb80..061a60e1 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -104,6 +104,7 @@ Russian By Artist By Name + By Genre Most Played Top Rated Recently Added