From 4068c3e009b220a5f31a3dfbc25723e26b3cadc2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 22 Mar 2023 19:58:06 -0600 Subject: [PATCH] detail: add playlist view Add a detail view for playlists. This is most equivelent to the genre detail view right now, but will be differentiated eventually. --- .../java/org/oxycblt/auxio/IntegerTable.kt | 20 +- .../auxio/detail/AlbumDetailFragment.kt | 4 + .../auxio/detail/ArtistDetailFragment.kt | 10 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 73 +++++- .../auxio/detail/GenreDetailFragment.kt | 10 +- .../auxio/detail/PlaylistDetailFragment.kt | 242 ++++++++++++++++++ .../detail/header/AlbumDetailHeaderAdapter.kt | 1 + .../header/ArtistDetailHeaderAdapter.kt | 1 + .../detail/header/GenreDetailHeaderAdapter.kt | 6 +- .../header/PlaylistDetailHeaderAdapter.kt | 85 ++++++ .../detail/list/GenreDetailListAdapter.kt | 2 +- .../detail/list/PlaylistDetailListAdapter.kt | 78 ++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 3 +- .../org/oxycblt/auxio/list/ListFragment.kt | 2 - .../main/java/org/oxycblt/auxio/list/Sort.kt | 58 +++-- .../org/oxycblt/auxio/music/MusicSettings.kt | 14 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 13 + .../auxio/playback/PlaybackViewModel.kt | 1 - .../oxycblt/auxio/search/SearchFragment.kt | 3 +- ...tist_detail.xml => menu_parent_detail.xml} | 0 app/src/main/res/menu/menu_playlist_sort.xml | 33 +++ app/src/main/res/navigation/nav_explore.xml | 21 ++ app/src/main/res/values/strings.xml | 1 + .../oxycblt/auxio/music/FakeMusicSettings.kt | 3 + 24 files changed, 628 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt rename app/src/main/res/menu/{menu_genre_artist_detail.xml => menu_parent_detail.xml} (100%) create mode 100644 app/src/main/res/menu/menu_playlist_sort.xml diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 47a4d9781..3e9a639c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -77,24 +77,26 @@ object IntegerTable { const val MUSIC_MODE_GENRES = 0xA108 /** MusicMode.PLAYLISTS */ const val MUSIC_MODE_PLAYLISTS = 0xA107 - /** Sort.ByName */ + /** Sort.Mode.ByName */ const val SORT_BY_NAME = 0xA10C - /** Sort.ByArtist */ + /** Sort.Mode.ByArtist */ const val SORT_BY_ARTIST = 0xA10D - /** Sort.ByAlbum */ + /** Sort.Mode.ByAlbum */ const val SORT_BY_ALBUM = 0xA10E - /** Sort.ByYear */ + /** Sort.Mode.ByYear */ const val SORT_BY_YEAR = 0xA10F - /** Sort.ByDuration */ + /** Sort.Mode.ByDuration */ const val SORT_BY_DURATION = 0xA114 - /** Sort.ByCount */ + /** Sort.Mode.ByCount */ const val SORT_BY_COUNT = 0xA115 - /** Sort.ByDisc */ + /** Sort.Mode.ByDisc */ const val SORT_BY_DISC = 0xA116 - /** Sort.ByTrack */ + /** Sort.Mode.ByTrack */ const val SORT_BY_TRACK = 0xA117 - /** Sort.ByDateAdded */ + /** Sort.Mode.ByDateAdded */ const val SORT_BY_DATE_ADDED = 0xA118 + /** Sort.Mode.None */ + const val SORT_BY_NONE = 0xA11F /** ReplayGainMode.Off (No longer used but still reserved) */ // const val REPLAY_GAIN_MODE_OFF = 0xA110 /** ReplayGainMode.Track */ 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 12f7098cf..e253f0752 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -159,8 +159,10 @@ class AlbumDetailFragment : override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_album_sort) { + // Select the corresponding sort mode option val sort = detailModel.albumSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true + // Select the corresponding sort direction option val directionItemId = when (sort.direction) { Sort.Direction.ASCENDING -> R.id.option_sort_asc @@ -171,8 +173,10 @@ class AlbumDetailFragment : item.isChecked = !item.isChecked detailModel.albumSongSort = when (item.itemId) { + // Sort direction options R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) + // Any other option is a sort mode else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) } true 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 eecd5b5bc..ed3ff8f13 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -87,7 +87,7 @@ class ArtistDetailFragment : // --- UI SETUP --- binding.detailToolbar.apply { - inflateMenu(R.menu.menu_genre_artist_detail) + inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) } @@ -97,7 +97,7 @@ class ArtistDetailFragment : // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. detailModel.setArtistUid(args.artistUid) - collectImmediately(detailModel.currentArtist, ::updateItem) + collectImmediately(detailModel.currentArtist, ::updateArtist) collectImmediately(detailModel.artistList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) @@ -171,8 +171,10 @@ class ArtistDetailFragment : override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_artist_sort) { + // Select the corresponding sort mode option val sort = detailModel.artistSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true + // Select the corresponding sort direction option val directionItemId = when (sort.direction) { Sort.Direction.ASCENDING -> R.id.option_sort_asc @@ -184,8 +186,10 @@ class ArtistDetailFragment : detailModel.artistSongSort = when (item.itemId) { + // Sort direction options R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) + // Any other option is a sort mode else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) } @@ -194,7 +198,7 @@ class ArtistDetailFragment : } } - private fun updateItem(artist: Artist?) { + private fun updateArtist(artist: Artist?) { if (artist == null) { // Artist we were showing no longer exists. findNavController().navigateUp() 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 f2527a752..8369fcf67 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -143,6 +143,31 @@ constructor( currentGenre.value?.let { refreshGenreList(it, true) } } + // --- PLAYLIST --- + private val _currentPlaylist = MutableStateFlow(null) + /** The current [Playlist] to display. Null if there is nothing to do. */ + val currentPlaylist: StateFlow + get() = _currentPlaylist + + private val _playlistList = MutableStateFlow(listOf()) + /** The current list data derived from [currentPlaylist] */ + val playlistList: StateFlow> = _playlistList + private val _playlistInstructions = MutableEvent() + /** Instructions for updating [playlistList] in the UI. */ + val playlistInstructions: Event + get() = _playlistInstructions + + /** The current [Sort] used for [Song]s in [playlistList]. */ + var playlistSongSort: Sort + get() = musicSettings.playlistSongSort + set(value) { + logD(value) + musicSettings.playlistSongSort = value + logD(musicSettings.playlistSongSort) + // Refresh the playlist list to reflect the new sort. + currentPlaylist.value?.let { refreshPlaylistList(it, true) } + } + /** * The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently * shown item. @@ -161,6 +186,7 @@ constructor( override fun onMusicChanges(changes: MusicRepository.Changes) { if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return // If we are showing any item right now, we will need to refresh it (and any information // related to it) with the new library in order to prevent stale items from showing up @@ -175,13 +201,13 @@ constructor( val album = currentAlbum.value if (album != null) { _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) - logD("Updated genre to ${currentAlbum.value}") + logD("Updated album to ${currentAlbum.value}") } val artist = currentArtist.value if (artist != null) { _currentArtist.value = deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList) - logD("Updated genre to ${currentArtist.value}") + logD("Updated artist to ${currentArtist.value}") } val genre = currentGenre.value @@ -189,6 +215,12 @@ constructor( _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList) logD("Updated genre to ${currentGenre.value}") } + + val playlist = currentPlaylist.value + if (playlist != null) { + _currentPlaylist.value = + userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) + } } /** @@ -254,6 +286,22 @@ constructor( musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) } + /** + * Set a new [currentPlaylist] from it's [Music.UID]. If the [Music.UID] differs, + * [currentPlaylist] and [currentPlaylist] will be updated to align with the new album. + * + * @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid. + */ + fun setPlaylistUid(uid: Music.UID) { + if (_currentPlaylist.value?.uid == uid) { + // Nothing to do. + return + } + logD("Opening Playlist [uid: $uid]") + _currentPlaylist.value = + musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) + } + private fun refreshAudioInfo(song: Song) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() @@ -267,7 +315,7 @@ constructor( } private fun refreshAlbumList(album: Album, replace: Boolean = false) { - logD("Refreshing album data") + logD("Refreshing album list") val list = mutableListOf() list.add(SortHeader(R.string.lbl_songs)) val instructions = @@ -299,7 +347,7 @@ constructor( } private fun refreshArtistList(artist: Artist, replace: Boolean = false) { - logD("Refreshing artist data") + logD("Refreshing artist list") val list = mutableListOf() val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums) @@ -348,7 +396,7 @@ constructor( } private fun refreshGenreList(genre: Genre, replace: Boolean = false) { - logD("Refreshing genre data") + logD("Refreshing genre list") val list = mutableListOf() // Genre is guaranteed to always have artists and songs. list.add(BasicHeader(R.string.lbl_artists)) @@ -366,6 +414,21 @@ constructor( _genreList.value = list } + private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) { + logD("Refreshing playlist list") + val list = mutableListOf() + list.add(SortHeader(R.string.lbl_songs)) + val instructions = + if (replace) { + UpdateInstructions.Replace(list.size) + } else { + UpdateInstructions.Diff + } + list.addAll(playlistSongSort.songs(playlist.songs)) + _playlistInstructions.put(instructions) + _playlistList.value = list + } + /** * A simpler mapping of [ReleaseType] used for grouping and sorting songs. * 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 f15ca89ac..ebcc60a02 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -81,7 +81,7 @@ class GenreDetailFragment : // --- UI SETUP --- binding.detailToolbar.apply { - inflateMenu(R.menu.menu_genre_artist_detail) + inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) } @@ -91,7 +91,7 @@ class GenreDetailFragment : // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. detailModel.setGenreUid(args.genreUid) - collectImmediately(detailModel.currentGenre, ::updateItem) + collectImmediately(detailModel.currentGenre, ::updatePlaylist) collectImmediately(detailModel.genreList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) @@ -165,8 +165,10 @@ class GenreDetailFragment : override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_genre_sort) { + // Select the corresponding sort mode option val sort = detailModel.genreSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true + // Select the corresponding sort direction option val directionItemId = when (sort.direction) { Sort.Direction.ASCENDING -> R.id.option_sort_asc @@ -177,8 +179,10 @@ class GenreDetailFragment : item.isChecked = !item.isChecked detailModel.genreSongSort = when (item.itemId) { + // Sort direction options R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) + // Any other option is a sort mode else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) } true @@ -186,7 +190,7 @@ class GenreDetailFragment : } } - private fun updateItem(genre: Genre?) { + private fun updatePlaylist(genre: Genre?) { if (genre == null) { // Genre we were showing no longer exists. findNavController().navigateUp() diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt new file mode 100644 index 000000000..e0b145b04 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistDetailFragment.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.detail + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.transition.MaterialSharedAxis +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.FragmentDetailBinding +import org.oxycblt.auxio.detail.header.DetailHeaderAdapter +import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter +import org.oxycblt.auxio.detail.list.DetailListAdapter +import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.NavigationViewModel +import org.oxycblt.auxio.util.* + +/** + * A [ListFragment] that shows information for a particular [Playlist]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlaylistDetailFragment : + ListFragment(), + DetailHeaderAdapter.Listener, + DetailListAdapter.Listener { + private val detailModel: DetailViewModel by activityViewModels() + override val navModel: NavigationViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() + override val selectionModel: SelectionViewModel by activityViewModels() + // Information about what playlist to display is initially within the navigation arguments + // as a UID, as that is the only safe way to parcel an playlist. + private val args: PlaylistDetailFragmentArgs by navArgs() + private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this) + private val playlistListAdapter = PlaylistDetailListAdapter(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) + + override fun getSelectionToolbar(binding: FragmentDetailBinding) = + binding.detailSelectionToolbar + + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- UI SETUP --- + binding.detailToolbar.apply { + inflateMenu(R.menu.menu_parent_detail) + setNavigationOnClickListener { findNavController().navigateUp() } + setOnMenuItemClickListener(this@PlaylistDetailFragment) + } + + binding.detailRecycler.adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + + // --- VIEWMODEL SETUP --- + // DetailViewModel handles most initialization from the navigation argument. + detailModel.setPlaylistUid(args.playlistUid) + collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) + collectImmediately(detailModel.playlistList, ::updateList) + collectImmediately( + playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collectImmediately(selectionModel.selected, ::updateSelection) + } + + override fun onDestroyBinding(binding: FragmentDetailBinding) { + super.onDestroyBinding(binding) + binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailRecycler.adapter = null + // Avoid possible race conditions that could cause a bad replace instruction to be consumed + // during list initialization and crash the app. Could happen if the user is fast enough. + detailModel.playlistInstructions.consume() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + // TODO: Handle + val currentPlaylist = unlikelyToBeNull(detailModel.currentPlaylist.value) + return when (item.itemId) { + R.id.action_play_next -> { + // playbackModel.playNext(currentPlaylist) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + // playbackModel.addToQueue(currentPlaylist) + requireContext().showToast(R.string.lng_queue_added) + true + } + else -> false + } + } + + override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { + // TODO: Handle + } + + override fun onRealClick(item: Song) { + // TODO: Handle + } + + override fun onOpenMenu(item: Song, anchor: View) { + openMusicMenu(anchor, R.menu.menu_song_actions, item) + } + + override fun onPlay() { + // TODO: Handle + // playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) + } + + override fun onShuffle() { + // playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) + } + + override fun onOpenSortMenu(anchor: View) { + openMenu(anchor, R.menu.menu_playlist_sort) { + // Select the corresponding sort mode option + val sort = detailModel.playlistSongSort + unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true + // Select the corresponding sort direction option + val directionItemId = + when (sort.direction) { + Sort.Direction.ASCENDING -> R.id.option_sort_asc + Sort.Direction.DESCENDING -> R.id.option_sort_dec + } + unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true + // If there is no sort specified, disable the ascending/descending options, as + // they make no sense. We still do want to indicate the state however, in the case + // that the user wants to switch to a sort mode where they do make sense. + if (sort.mode is Sort.Mode.ByNone) { + menu.findItem(R.id.option_sort_dec).isEnabled = false + menu.findItem(R.id.option_sort_asc).isEnabled = false + } + + setOnMenuItemClickListener { item -> + item.isChecked = !item.isChecked + detailModel.playlistSongSort = + when (item.itemId) { + // Sort direction options + R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) + R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) + // Any other option is a sort mode + else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) + } + true + } + } + } + + private fun updatePlaylist(playlist: Playlist?) { + if (playlist == null) { + // Playlist we were showing no longer exists. + findNavController().navigateUp() + return + } + requireBinding().detailToolbar.title = playlist.resolveName(requireContext()) + playlistHeaderAdapter.setParent(playlist) + } + + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + // Prefer songs that might be playing from this playlist. + if (parent is Playlist && + parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) { + playlistListAdapter.setPlaying(song, isPlaying) + } else { + playlistListAdapter.setPlaying(null, isPlaying) + } + } + + private fun handleNavigation(item: Music?) { + when (item) { + is Song -> { + logD("Navigating to another song") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.album.uid)) + } + is Album -> { + logD("Navigating to another album") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.uid)) + } + is Artist -> { + logD("Navigating to another artist") + findNavController() + .navigateSafe(PlaylistDetailFragmentDirections.actionShowArtist(item.uid)) + } + is Playlist -> { + navModel.exploreNavigationItem.consume() + } + else -> {} + } + } + + private fun updateList(list: List) { + playlistListAdapter.update(list, detailModel.playlistInstructions.consume()) + } + + private fun updateSelection(selected: List) { + playlistListAdapter.setSelected(selected.toSet()) + requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt index 07a552c55..c7747ce2c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater /** * A [DetailHeaderAdapter] that shows [Album] information. * + * @param listener [DetailHeaderAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ class AlbumDetailHeaderAdapter(private val listener: Listener) : diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt index 1268f7caf..0e0e0a691 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater /** * A [DetailHeaderAdapter] that shows [Artist] information. * + * @param listener [DetailHeaderAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ class ArtistDetailHeaderAdapter(private val listener: Listener) : diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt index 23e4ca855..99a816391 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt @@ -24,7 +24,6 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding -import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -33,6 +32,7 @@ import org.oxycblt.auxio.util.inflater /** * A [DetailHeaderAdapter] that shows [Genre] information. * + * @param listener [DetailHeaderAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ class GenreDetailHeaderAdapter(private val listener: Listener) : @@ -57,7 +57,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : * Bind new data to this instance. * * @param genre The new [Genre] to bind. - * @param listener A [DetailListAdapter.Listener] to bind interactions to. + * @param listener A [DetailHeaderAdapter.Listener] to bind interactions to. */ fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) { binding.detailCover.bind(genre) @@ -65,7 +65,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailName.text = genre.resolveName(binding.context) // Nothing about a genre is applicable to the sub-head text. binding.detailSubhead.isVisible = false - // The song count of the genre maps to the info text. + // The song and artist count of the genre maps to the info text. binding.detailInfo.text = binding.context.getString( R.string.fmt_two, diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt new file mode 100644 index 000000000..db6464f93 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistDetailHeaderAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.detail.header + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.getPlural +import org.oxycblt.auxio.util.inflater + +/** + * A [DetailHeaderAdapter] that shows [Playlist] information. + * + * @param listener [DetailHeaderAdapter.Listener] to bind interactions to. + * @author Alexander Capehart (OxygenCobalt) + */ +class PlaylistDetailHeaderAdapter(private val listener: Listener) : + DetailHeaderAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PlaylistDetailHeaderViewHolder.from(parent) + + override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) = + holder.bind(parent, listener) +} + +/** + * A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to + * create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class PlaylistDetailHeaderViewHolder +private constructor(private val binding: ItemDetailHeaderBinding) : + RecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param playlist The new [Playlist] to bind. + * @param listener A [DetailHeaderAdapter.Listener] to bind interactions to. + */ + fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) { + binding.detailCover.bind(playlist) + binding.detailType.text = binding.context.getString(R.string.lbl_playlist) + binding.detailName.text = playlist.resolveName(binding.context) + // Nothing about a playlist is applicable to the sub-head text. + binding.detailSubhead.isVisible = false + // The song count of the playlist maps to the info text. + binding.detailInfo.text = + binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) + binding.detailPlayButton.setOnClickListener { listener.onPlay() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffle() } + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt index 67ebe3781..e4a33c80b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song /** - * An [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view. + * A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view. * * @param listener A [DetailListAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt new file mode 100644 index 000000000..a6c695ac2 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistDetailListAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.detail.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.SongViewHolder +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song + +/** + * A [DetailListAdapter] implementing the header and sub-items for the [Playlist] detail view. + * + * @param listener A [DetailListAdapter.Listener] to bind interactions to. + * @author Alexander Capehart (OxygenCobalt) + */ +class PlaylistDetailListAdapter(private val listener: Listener) : + DetailListAdapter(listener, DIFF_CALLBACK) { + override fun getItemViewType(position: Int) = + when (getItem(position)) { + // Support generic song items. + is Song -> SongViewHolder.VIEW_TYPE + else -> super.getItemViewType(position) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + if (viewType == SongViewHolder.VIEW_TYPE) { + SongViewHolder.from(parent) + } else { + super.onCreateViewHolder(parent, viewType) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val item = getItem(position) + if (item is Song) { + (holder as SongViewHolder).bind(item, listener) + } + } + + override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } + // Playlist headers should be full-width in all configurations + return getItem(position) is Playlist + } + + companion object { + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Item, newItem: Item) = + when { + oldItem is Song && newItem is Song -> + SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + } + } + } +} 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 8b7e9abae..62563f159 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -438,7 +438,8 @@ class HomeFragment : is Album -> HomeFragmentDirections.actionShowAlbum(item.uid) is Artist -> HomeFragmentDirections.actionShowArtist(item.uid) is Genre -> HomeFragmentDirections.actionShowGenre(item.uid) - else -> return + is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid) + null -> return } setupAxisTransitions(MaterialSharedAxis.X) diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index c3fc735b8..31aef6c1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -22,7 +22,6 @@ import android.view.MenuItem import android.view.View import androidx.annotation.MenuRes import androidx.appcompat.widget.PopupMenu -import androidx.core.internal.view.SupportMenu import androidx.core.view.MenuCompat import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding @@ -281,7 +280,6 @@ abstract class ListFragment : currentMenu = PopupMenu(requireContext(), anchor).apply { inflate(menuRes) - logD(menu is SupportMenu) MenuCompat.setGroupDividerEnabled(menu, true) block() setOnDismissListener { currentMenu = null } diff --git a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt index 0aed7203c..3be7b0051 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -114,23 +114,28 @@ data class Sort(val mode: Mode, val direction: Direction) { } private fun songsInPlace(songs: MutableList) { - songs.sortWith(mode.getSongComparator(direction)) + val comparator = mode.getSongComparator(direction) ?: return + songs.sortWith(comparator) } private fun albumsInPlace(albums: MutableList) { - albums.sortWith(mode.getAlbumComparator(direction)) + val comparator = mode.getAlbumComparator(direction) ?: return + albums.sortWith(comparator) } private fun artistsInPlace(artists: MutableList) { - artists.sortWith(mode.getArtistComparator(direction)) + val comparator = mode.getArtistComparator(direction) ?: return + artists.sortWith(comparator) } private fun genresInPlace(genres: MutableList) { - genres.sortWith(mode.getGenreComparator(direction)) + val comparator = mode.getGenreComparator(direction) ?: return + genres.sortWith(comparator) } private fun playlistsInPlace(playlists: MutableList) { - playlists.sortWith(mode.getPlaylistComparator(direction)) + val comparator = mode.getPlaylistComparator(direction) ?: return + playlists.sortWith(comparator) } /** @@ -160,50 +165,57 @@ data class Sort(val mode: Mode, val direction: Direction) { * Get a [Comparator] that sorts [Song]s according to this [Mode]. * * @param direction The direction to sort in. - * @return A [Comparator] that can be used to sort a [Song] list according to this [Mode]. + * @return A [Comparator] that can be used to sort a [Song] list according to this [Mode], + * or null to not sort at all. */ - open fun getSongComparator(direction: Direction): Comparator { - throw UnsupportedOperationException() - } + open fun getSongComparator(direction: Direction): Comparator? = null /** * Get a [Comparator] that sorts [Album]s according to this [Mode]. * * @param direction The direction to sort in. - * @return A [Comparator] that can be used to sort a [Album] list according to this [Mode]. + * @return A [Comparator] that can be used to sort a [Album] list according to this [Mode], + * or null to not sort at all. */ - open fun getAlbumComparator(direction: Direction): Comparator { - throw UnsupportedOperationException() - } - + open fun getAlbumComparator(direction: Direction): Comparator? = null /** * Return a [Comparator] that sorts [Artist]s according to this [Mode]. * * @param direction The direction to sort in. * @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode]. + * or null to not sort at all. */ - open fun getArtistComparator(direction: Direction): Comparator { - throw UnsupportedOperationException() - } + open fun getArtistComparator(direction: Direction): Comparator? = null /** * Return a [Comparator] that sorts [Genre]s according to this [Mode]. * * @param direction The direction to sort in. * @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode]. + * or null to not sort at all. */ - open fun getGenreComparator(direction: Direction): Comparator { - throw UnsupportedOperationException() - } + open fun getGenreComparator(direction: Direction): Comparator? = null /** * Return a [Comparator] that sorts [Playlist]s according to this [Mode]. * * @param direction The direction to sort in. * @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode]. + * or null to not sort at all. */ - open fun getPlaylistComparator(direction: Direction): Comparator { - throw UnsupportedOperationException() + open fun getPlaylistComparator(direction: Direction): Comparator? = null + + /** + * Sort by the item's natural order. + * + * @see Music.sortName + */ + object ByNone : Mode() { + override val intCode: Int + get() = IntegerTable.SORT_BY_NONE + + override val itemId: Int + get() = R.id.option_sort_none } /** @@ -614,6 +626,7 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun fromIntCode(intCode: Int) = when (intCode) { + ByNone.intCode -> ByNone ByName.intCode -> ByName ByArtist.intCode -> ByArtist ByAlbum.intCode -> ByAlbum @@ -635,6 +648,7 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun fromItemId(@IdRes itemId: Int) = when (itemId) { + ByNone.itemId -> ByNone ByName.itemId -> ByName ByAlbum.itemId -> ByAlbum ByArtist.itemId -> ByArtist diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 23ce121ad..ebd3c8152 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -46,6 +46,7 @@ interface MusicSettings : Settings { var multiValueSeparators: String /** Whether to trim english articles with song sort names. */ val automaticSortNames: Boolean + // TODO: Move sort settings to list module /** The [Sort] mode used in [Song] lists. */ var songSort: Sort /** The [Sort] mode used in [Album] lists. */ @@ -62,8 +63,8 @@ interface MusicSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in a [Genre]'s [Song] list. */ var genreSongSort: Sort - /** The [Sort] mode used in a [Playlist]'s [Song] list, or null if sorting by original ordering. */ - var playlistSongSort: Sort? + /** The [Sort] mode used in a [Playlist]'s [Song] list. */ + var playlistSongSort: Sort interface Listener { /** Called when a setting controlling how music is loaded has changed. */ @@ -224,12 +225,15 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context } } - override var playlistSongSort: Sort? - get() = Sort.fromIntCode(sharedPreferences.getInt( + override var playlistSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( getString(R.string.set_key_playlist_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByNone, Sort.Direction.ASCENDING) set(value) { sharedPreferences.edit { - putInt(getString(R.string.lbl_playlist), value?.intCode ?: -1) + putInt(getString(R.string.set_key_playlist_songs_sort), value.intCode) apply() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 5e1b86860..593321c71 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.user import javax.inject.Inject import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.device.DeviceLibrary @@ -75,5 +76,17 @@ private class UserLibraryImpl( override val playlists: List get() = playlistMap.values.toList() + init { + val uid = Music.UID.auxio(MusicMode.PLAYLISTS) { update("Playlist 1".toByteArray()) } + playlistMap[uid] = + PlaylistImpl( + RawPlaylist( + PlaylistInfo(uid, "Playlist 1"), + deviceLibrary.songs.slice(10..30).map { PlaylistSong(it.uid) }), + deviceLibrary, + musicSettings, + ) + } + override fun findPlaylist(uid: Music.UID) = playlistMap[uid] } 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 f000a02b5..2b3d43e9e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.room.util.query import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job 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 c0facb71e..b7b693a85 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -178,7 +178,8 @@ class SearchFragment : ListFragment() { is Album -> SearchFragmentDirections.actionShowAlbum(item.uid) is Artist -> SearchFragmentDirections.actionShowArtist(item.uid) is Genre -> SearchFragmentDirections.actionShowGenre(item.uid) - else -> return + is Playlist -> SearchFragmentDirections.actionShowPlaylist(item.uid) + null -> return } // Keyboard is no longer needed. hideKeyboard() diff --git a/app/src/main/res/menu/menu_genre_artist_detail.xml b/app/src/main/res/menu/menu_parent_detail.xml similarity index 100% rename from app/src/main/res/menu/menu_genre_artist_detail.xml rename to app/src/main/res/menu/menu_parent_detail.xml diff --git a/app/src/main/res/menu/menu_playlist_sort.xml b/app/src/main/res/menu/menu_playlist_sort.xml new file mode 100644 index 000000000..167fae26b --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_sort.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_explore.xml b/app/src/main/res/navigation/nav_explore.xml index a7ecabf0f..dfce49cb9 100644 --- a/app/src/main/res/navigation/nav_explore.xml +++ b/app/src/main/res/navigation/nav_explore.xml @@ -49,11 +49,29 @@ android:id="@+id/action_show_album" app:destination="@id/album_detail_fragment" /> + + + + + + @@ -72,6 +90,9 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af89461bd..363bbad06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ All + None Name Date Duration diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt index 83b6f7e2d..3bed80869 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -60,4 +60,7 @@ open class FakeMusicSettings : MusicSettings { override var genreSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() + override var playlistSongSort: Sort? + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() }