detail: add playlist editing

Add the ability to edit a playlist in it's detail view.

This replaces the prior sorting functionality entirely. That will be
re-added later.
This commit is contained in:
Alexander Capehart 2023-05-19 11:15:33 -06:00
parent 33381f463a
commit 996c86b361
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
27 changed files with 634 additions and 158 deletions

View file

@ -49,6 +49,12 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** EditHeaderViewHolder */
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
/** ConfirmHeaderViewHolder */
const val VIEW_TYPE_CONFIRM_HEADER = 0xA00D
/** EditableSongViewHolder */
const val VIEW_TYPE_EDITABLE_SONG = 0xA00E
/** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */

View file

@ -22,6 +22,7 @@ import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.lang.Exception
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
@ -145,6 +147,7 @@ constructor(
}
// --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
@ -158,16 +161,13 @@ constructor(
val playlistInstructions: Event<UpdateInstructions>
get() = _playlistInstructions
private var isEditingPlaylist = false
/** The current [Sort] used for [Song]s in [playlistList]. */
var playlistSongSort: Sort
get() = musicSettings.playlistSongSort
set(value) {
musicSettings.playlistSongSort = value
// Refresh the playlist list to reflect the new sort.
currentPlaylist.value?.let { refreshPlaylistList(it, true) }
}
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/**
* The new playlist songs created during the current editing session. Null if no editing session
* is occurring.
*/
val editedPlaylist: StateFlow<List<Song>?>
get() = _editedPlaylist
/**
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
@ -220,6 +220,7 @@ constructor(
if (changes.userLibrary && userLibrary != null) {
val playlist = currentPlaylist.value
if (playlist != null) {
logD("Updated playlist to ${currentPlaylist.value}")
_currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
}
@ -285,6 +286,71 @@ constructor(
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
}
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
fun startPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
logD("Starting playlist edit")
_editedPlaylist.value = playlist.songs
refreshPlaylistList(playlist)
}
/**
* End a playlist editing session and commits it to the database. Does nothing if there was no
* prior editing session.
*/
fun confirmPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null }
musicRepository.rewritePlaylist(playlist, editedPlaylist)
}
/**
* End a playlist editing session and keep the prior state. Does nothing if there was no prior
* editing session.
*/
fun dropPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
_editedPlaylist.value = null
refreshPlaylistList(playlist)
}
/**
* (Visually) move a song in the current playlist. Does nothing if not in an editing session.
*
* @param from The start position, in the list adapter data.
* @param to The destination position, in the list adapter data.
*/
fun movePlaylistSongs(from: Int, to: Int): Boolean {
val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 2
val realTo = to - 2
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false
}
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
return true
}
/**
* (Visually) remove a song in the current playlist. Does nothing if not in an editing session.
*
* @param at The position of the item to remove, in the list adapter data.
*/
fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2
if (realAt !in editedPlaylist.indices) {
return
}
editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Remove(at))
}
private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
@ -408,21 +474,26 @@ constructor(
_genreList.value = list
}
private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) {
private fun refreshPlaylistList(
playlist: Playlist,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
logD(Exception().stackTraceToString())
logD("Refreshing playlist list")
var instructions: UpdateInstructions = UpdateInstructions.Diff
val list = mutableListOf<Item>()
if (playlist.songs.isNotEmpty()) {
val header = BasicHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
if (replace) {
instructions = UpdateInstructions.Replace(list.size)
val newInstructions =
if (playlist.songs.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
list.addAll(_editedPlaylist.value ?: playlist.songs)
instructions
} else {
UpdateInstructions.Diff
}
list.addAll(playlistSongSort.songs(playlist.songs))
}
_playlistInstructions.put(instructions)
_playlistInstructions.put(newInstructions)
_playlistList.value = list
}

View file

@ -23,23 +23,26 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
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.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
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.navigation.NavigationViewModel
@ -55,7 +58,8 @@ import org.oxycblt.auxio.util.*
class PlaylistDetailFragment :
ListFragment<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
PlaylistDetailListAdapter.Listener,
NavController.OnDestinationChangedListener {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
@ -66,6 +70,8 @@ class PlaylistDetailFragment :
private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var initialNavDestinationChange = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -92,6 +98,10 @@ class PlaylistDetailFragment :
binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(this)
}
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.playlistList.value[it - 1]
@ -107,21 +117,52 @@ class PlaylistDetailFragment :
detailModel.setPlaylistUid(args.playlistUid)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedPlaylist)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
override fun onStart() {
super.onStart()
// Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag.
initialNavDestinationChange = false
findNavController().addOnDestinationChangedListener(this)
}
override fun onStop() {
super.onStop()
findNavController().removeOnDestinationChangedListener(this)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
touchHelper = 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 onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
// Drop the initial call by NavController that simply provides us with the current
// destination. This would cause the selection state to be lost every time the device
// rotates.
if (!initialNavDestinationChange) {
initialNavDestinationChange = true
return
}
// Drop any pending playlist edits when navigating away.
detailModel.dropPlaylistEdit()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
@ -155,7 +196,12 @@ class PlaylistDetailFragment :
playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
override fun onOpenMenu(item: Song, anchor: View) {
// TODO: Remove "Add to playlist" option, makes no sense
openMusicMenu(anchor, R.menu.menu_song_actions, item)
}
@ -167,39 +213,21 @@ class PlaylistDetailFragment :
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
}
override fun onStartEdit() {
selectionModel.drop()
detailModel.startPlaylistEdit()
}
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
}
}
override fun onConfirmEdit() {
detailModel.confirmPlaylistEdit()
}
override fun onDropEdit() {
detailModel.dropPlaylistEdit()
}
override fun onOpenSortMenu(anchor: View) {
throw IllegalStateException()
}
private fun updatePlaylist(playlist: Playlist?) {
@ -250,6 +278,12 @@ class PlaylistDetailFragment :
playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
}
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
// TODO: Disable check item when no edits have been made
// TODO: Improve how this state change looks
playlistListAdapter.setEditing(editedPlaylist != null)
}
private fun updateSelection(selected: List<Music>) {
playlistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)

View file

@ -111,8 +111,8 @@ abstract class DetailListAdapter(
data class SortHeader(@StringRes override val titleRes: Int) : Header
/**
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
* a button opening a menu for sorting. Use [from] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
* an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -126,7 +126,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
*/
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
binding.headerButton.apply {
binding.headerSort.apply {
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
TooltipCompat.setTooltipText(this, contentDescription)

View file

@ -18,53 +18,286 @@
package org.oxycblt.auxio.detail.list
import android.annotation.SuppressLint
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Playlist] detail view.
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
* detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailListAdapter(private val listener: Listener<Song>) :
class PlaylistDetailListAdapter(private val listener: Listener) :
DetailListAdapter(listener, DIFF_CALLBACK) {
private var isEditing = false
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support generic song items.
is Song -> SongViewHolder.VIEW_TYPE
is EditHeader -> EditHeaderViewHolder.VIEW_TYPE
is Song -> PlaylistSongViewHolder.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)
when (viewType) {
EditHeaderViewHolder.VIEW_TYPE -> EditHeaderViewHolder.from(parent)
PlaylistSongViewHolder.VIEW_TYPE -> PlaylistSongViewHolder.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 onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: List<Any>
) {
super.onBindViewHolder(holder, position, payloads)
if (payloads.isEmpty()) {
when (val item = getItem(position)) {
is EditHeader -> (holder as EditHeaderViewHolder).bind(item, listener)
is Song -> (holder as PlaylistSongViewHolder).bind(item, listener)
}
}
if (holder is ViewHolder) {
holder.updateEditing(isEditing)
}
}
companion object {
fun setEditing(editing: Boolean) {
if (editing == isEditing) {
// Nothing to do.
return
}
this.isEditing = editing
notifyItemRangeChanged(1, currentList.size - 2, PAYLOAD_EDITING_CHANGED)
}
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
interface Listener : DetailListAdapter.Listener<Song>, EditableListListener {
/** Called when the "edit" option is selected in the edit header. */
fun onStartEdit()
/** Called when the "confirm" option is selected in the edit header. */
fun onConfirmEdit()
/** Called when the "cancel" option is selected in the edit header. */
fun onDropEdit()
}
/**
* A [RecyclerView.ViewHolder] extension required to respond to changes in the editing state.
*/
interface ViewHolder {
/**
* Called when the editing state changes. Implementations should update UI options as needed
* to reflect the new state.
*
* @param editing Whether the data is currently being edited or not.
*/
fun updateEditing(editing: Boolean)
}
private companion object {
val PAYLOAD_EDITING_CHANGED = Any()
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Song && newItem is Song ->
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
PlaylistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(
oldItem, newItem)
oldItem is EditHeader && newItem is EditHeader ->
EditHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
* an instance.
*
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class EditHeader(@StringRes override val titleRes: Int) : Header
/** Displays an [EditHeader] and it's actions. Use [from] to create an instance. */
private class EditHeaderViewHolder private constructor(private val binding: ItemEditHeaderBinding) :
RecyclerView.ViewHolder(binding.root), PlaylistDetailListAdapter.ViewHolder {
/**
* Bind new data to this instance.
*
* @param editHeader The new [EditHeader] to bind.
* @param listener An [PlaylistDetailListAdapter.Listener] to bind interactions to.
*/
fun bind(editHeader: EditHeader, listener: PlaylistDetailListAdapter.Listener) {
binding.headerTitle.text = binding.context.getString(editHeader.titleRes)
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
binding.headerEdit.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onStartEdit() }
}
binding.headerConfirm.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onConfirmEdit() }
}
binding.headerCancel.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onDropEdit() }
}
}
override fun updateEditing(editing: Boolean) {
binding.headerEdit.apply {
isGone = editing
jumpDrawablesToCurrentState()
}
binding.headerConfirm.apply {
isVisible = editing
jumpDrawablesToCurrentState()
}
binding.headerCancel.apply {
isVisible = editing
jumpDrawablesToCurrentState()
}
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_EDIT_HEADER
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
EditHeaderViewHolder(ItemEditHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<EditHeader>() {
override fun areContentsTheSame(oldItem: EditHeader, newItem: EditHeader) =
oldItem.titleRes == newItem.titleRes
}
}
}
/**
* A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song] which can be re-ordered and
* removed. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class PlaylistSongViewHolder
private constructor(private val binding: ItemEditableSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root),
MaterialDragCallback.ViewHolder,
PlaylistDetailListAdapter.ViewHolder {
override val enabled: Boolean
get() = binding.songDragHandle.isVisible
override val root = binding.root
override val body = binding.body
override val delete = binding.background
override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0
}
init {
binding.body.background =
LayerDrawable(
arrayOf(
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
},
background))
}
/**
* Bind new data to this instance.
*
* @param song The new [Song] to bind.
* @param listener A [PlaylistDetailListAdapter.Listener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(song: Song, listener: PlaylistDetailListAdapter.Listener) {
listener.bind(song, this, binding.interactBody, menuButton = binding.songMenu)
listener.bind(this, binding.songDragHandle)
binding.songAlbumCover.bind(song)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(binding.context)
// Not swiping this ViewHolder if it's being re-bound, ensure that the background is
// not visible. See MaterialDragCallback for why this is done.
binding.background.isInvisible = true
}
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.songAlbumCover.isActivated = isSelected
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.interactBody.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
override fun updateEditing(editing: Boolean) {
binding.songDragHandle.isInvisible = !editing
binding.songMenu.isInvisible = editing
binding.interactBody.apply {
isClickable = !editing
isFocusable = !editing
}
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_EDITABLE_SONG
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistSongViewHolder(ItemEditableSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDragCallback.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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.list
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
/**
* A [MaterialDragCallback] extension for playlist-specific item editing.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDragCallback(private val detailModel: DetailViewModel) : MaterialDragCallback() {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
) =
detailModel.movePlaylistSongs(
viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
detailModel.removePlaylistSong(viewHolder.bindingAdapterPosition)
}
}

View file

@ -24,7 +24,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater
@ -32,9 +32,9 @@ import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
*
* @param listener A [EditableListListener] for tab interactions.
* @param listener A [EditClickListListener] for tab interactions.
*/
class TabAdapter(private val listener: EditableListListener<Tab>) :
class TabAdapter(private val listener: EditClickListListener<Tab>) :
RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>()
@ -97,10 +97,10 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* Bind new data to this instance.
*
* @param tab The new [Tab] to bind.
* @param listener A [EditableListListener] to bind interactions to.
* @param listener A [EditClickListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: EditableListListener<Tab>) {
fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD
*/
@AndroidEntryPoint
class TabCustomizeDialog :
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
@Inject lateinit var homeSettings: HomeSettings

View file

@ -39,6 +39,7 @@ class SongKeyer @Inject constructor() : Keyer<Song> {
override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}"
}
// TODO: Key on the actual mosaic items used
class ParentKeyer @Inject constructor() : Keyer<MusicParent> {
override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}"
}

View file

@ -50,11 +50,11 @@ interface ClickableListListener<in T> {
}
/**
* An extension of [ClickableListListener] that enables list editing functionality.
* A listener for lists that can be edited.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditableListListener<in T> : ClickableListListener<T> {
interface EditableListListener {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
*
@ -62,6 +62,29 @@ interface EditableListListener<in T> : ClickableListListener<T> {
*/
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
/**
* Binds this instance to a list item.
*
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
*/
fun bind(viewHolder: RecyclerView.ViewHolder, dragHandle: View) {
dragHandle.setOnTouchListener { _, motionEvent ->
dragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
onPickUp(viewHolder)
true
} else false
}
}
}
/**
* A listener for lists that can be clicked and edited at the same time.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditClickListListener<in T> : ClickableListListener<T>, EditableListListener {
/**
* Binds this instance to a list item.
*
@ -78,13 +101,7 @@ interface EditableListListener<in T> : ClickableListListener<T> {
dragHandle: View
) {
bind(item, viewHolder, bodyView)
dragHandle.setOnTouchListener { _, motionEvent ->
dragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
onPickUp(viewHolder)
true
} else false
}
bind(viewHolder, dragHandle)
}
}

View file

@ -206,19 +206,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/
fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = null
/**
* Sort by the item's natural order.
*
* @see Music.name
*/
object ByNone : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_NONE
override val itemId: Int
get() = R.id.option_sort_none
}
/**
* Sort by the item's name.
*
@ -455,7 +442,6 @@ 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
@ -477,7 +463,6 @@ 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

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* ExtendedDragCallback.kt is part of Auxio.
* MaterialDragCallback.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
@ -44,7 +44,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) =
if (viewHolder is ViewHolder) {
if (viewHolder is ViewHolder && viewHolder.enabled) {
makeFlag(
ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or
makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START)
@ -138,6 +138,8 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
/** Required [RecyclerView.ViewHolder] implementation that exposes the following. */
interface ViewHolder {
/** Whether this [ViewHolder] can be moved right now. */
val enabled: Boolean
/** The root view containing the delete scrim and information. */
val root: View
/** The body view containing music information. */

View file

@ -96,7 +96,7 @@ constructor(
is Album -> musicSettings.albumSongSort.songs(it.songs)
is Artist -> musicSettings.artistSongSort.songs(it.songs)
is Genre -> musicSettings.genreSongSort.songs(it.songs)
is Playlist -> musicSettings.playlistSongSort.songs(it.songs)
is Playlist -> it.songs
}
}
.also { drop() }

View file

@ -141,6 +141,14 @@ interface MusicRepository {
*/
fun addToPlaylist(songs: List<Song>, playlist: Playlist)
/**
* Update the [Song]s of a [Playlist].
*
* @param playlist The [Playlist] to update.
* @param songs The new [Song]s to be contained in the [Playlist].
*/
fun rewritePlaylist(playlist: Playlist, songs: List<Song>)
/**
* Request that a music loading operation is started by the current [IndexingWorker]. Does
* nothing if one is not available.
@ -304,6 +312,15 @@ constructor(
}
}
override fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val userLibrary = userLibrary ?: return
userLibrary.rewritePlaylist(playlist, songs)
for (listener in updateListeners) {
listener.onMusicChanges(
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
}
}
override fun requestIndex(withCache: Boolean) {
indexingWorker?.requestIndex(withCache)
}

View file

@ -63,8 +63,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
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. */
var playlistSongSort: Sort
interface Listener {
/** Called when a setting controlling how music is loaded has changed. */
@ -225,19 +223,6 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
}
}
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.set_key_playlist_songs_sort), value.intCode)
apply()
}
}
override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data)

View file

@ -131,7 +131,7 @@ class IndexerService :
override val scope = indexScope
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
// TODO: Do not pause when playlist changes
val deviceLibrary = musicRepository.deviceLibrary ?: return
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()

View file

@ -101,6 +101,14 @@ interface MutableUserLibrary : UserLibrary {
* @param playlist The [Playlist] to add to. Must currently exist.
*/
fun addToPlaylist(playlist: Playlist, songs: List<Song>)
/**
* Update the [Song]s of a [Playlist].
*
* @param playlist The [Playlist] to update.
* @param songs The new [Song]s to be contained in the [Playlist].
*/
fun rewritePlaylist(playlist: Playlist, songs: List<Song>)
}
class UserLibraryFactoryImpl
@ -148,4 +156,11 @@ private class UserLibraryImpl(
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) }
}
@Synchronized
override fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit(songs)
}
}

View file

@ -306,16 +306,14 @@ constructor(
"Song to play not in parent"
}
val deviceLibrary = musicRepository.deviceLibrary ?: return
val sort =
val queue =
when (parent) {
is Genre -> musicSettings.genreSongSort
is Artist -> musicSettings.artistSongSort
is Album -> musicSettings.albumSongSort
is Playlist -> musicSettings.playlistSongSort
null -> musicSettings.songSort
is Genre -> musicSettings.genreSongSort.songs(parent.songs)
is Artist -> musicSettings.artistSongSort.songs(parent.songs)
is Album -> musicSettings.albumSongSort.songs(parent.songs)
is Playlist -> parent.songs
null -> musicSettings.songSort.songs(deviceLibrary.songs)
}
val songs = parent?.songs ?: deviceLibrary.songs
val queue = sort.songs(songs)
playbackManager.play(song, parent, queue, shuffled)
}
@ -394,7 +392,7 @@ constructor(
* @param playlist The [Playlist] to add.
*/
fun playNext(playlist: Playlist) {
playbackManager.playNext(musicSettings.playlistSongSort.songs(playlist.songs))
playbackManager.playNext(playlist.songs)
}
/**
@ -448,7 +446,7 @@ constructor(
* @param playlist The [Playlist] to add.
*/
fun addToQueue(playlist: Playlist) {
playbackManager.addToQueue(musicSettings.playlistSongSort.songs(playlist.songs))
playbackManager.addToQueue(playlist.songs)
}
/**

View file

@ -27,7 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
@ -38,10 +38,10 @@ import org.oxycblt.auxio.util.*
/**
* A [RecyclerView.Adapter] that shows an editable list of queue items.
*
* @param listener A [EditableListListener] to bind interactions to.
* @param listener A [EditClickListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueAdapter(private val listener: EditableListListener<Song>) :
class QueueAdapter(private val listener: EditClickListListener<Song>) :
FlexibleListAdapter<Song, QueueSongViewHolder>(QueueSongViewHolder.DIFF_CALLBACK) {
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
// adapter, as one item can appear at several points in the UI. Use a similar implementation
@ -97,22 +97,17 @@ class QueueAdapter(private val listener: EditableListListener<Song>) :
}
/**
* A [PlayingIndicatorAdapter.ViewHolder] that displays an editable [Song] which can be re-ordered
* and removed. Use [from] to create an instance.
* A [PlayingIndicatorAdapter.ViewHolder] that displays an queue [Song] which can be re-ordered and
* removed. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) :
PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder {
override val root: View
get() = binding.root
override val body: View
get() = binding.body
override val delete: View
get() = binding.background
override val enabled = true
override val root = binding.root
override val body = binding.body
override val delete = binding.background
override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
@ -143,10 +138,10 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS
* Bind new data to this instance.
*
* @param song The new [Song] to bind.
* @param listener A [EditableListListener] to bind interactions to.
* @param listener A [EditClickListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(song: Song, listener: EditableListListener<Song>) {
fun bind(song: Song, listener: EditClickListListener<Song>) {
listener.bind(song, this, body, binding.songDragHandle)
binding.songAlbumCover.bind(song)
binding.songName.text = song.name.resolve(binding.context)

View file

@ -28,7 +28,7 @@ import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickListListener<Song> {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val queueAdapter = QueueAdapter(this)

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L658,133Q681,110 714.5,110Q748,110 771,133L827,189Q850,212 851,244.5Q852,277 829,300L772,357ZM714,416L290,840L120,840L120,670L544,246L714,416ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
</vector>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:background="?attr/colorSurface"
android:orientation="horizontal"
android:layout_height="wrap_content">
<TextView
android:id="@+id/header_title"
style="@style/Widget.Auxio.TextView.Header"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
app:layout_constraintEnd_toStartOf="@+id/header_button"
app:layout_constraintStart_toStartOf="parent"
tools:text="Songs" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_edit"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:contentDescription="@string/lbl_edit"
app:icon="@drawable/ic_edit_24"
app:layout_constraintEnd_toEndOf="parent" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_confirm"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/lbl_ok"
app:icon="@drawable/ic_check_24"
app:layout_constraintEnd_toEndOf="parent" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_cancel"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:contentDescription="@string/lbl_cancel"
app:icon="@drawable/ic_close_24"
app:layout_constraintEnd_toEndOf="parent" />
</LinearLayout>

View file

@ -85,6 +85,19 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/song_album_cover" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/song_menu"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/desc_song_handle"
android:visibility="gone"
app:icon="@drawable/ic_more_24"
app:layout_constraintBottom_toBottomOf="@+id/song_drag_handle"
app:layout_constraintEnd_toEndOf="@+id/song_drag_handle"
app:layout_constraintStart_toStartOf="@id/song_drag_handle"
app:layout_constraintTop_toTopOf="@+id/song_drag_handle" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
@ -20,7 +19,7 @@
tools:text="Songs" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_button"
android:id="@+id/header_sort"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -83,6 +83,7 @@
<string name="lbl_rename_playlist">Rename playlist</string>
<string name="lbl_delete">Delete</string>
<string name="lbl_confirm_delete_playlist">Delete playlist?</string>
<string name="lbl_edit">Edit</string>
<!-- Search for music -->
<string name="lbl_search">Search</string>

View file

@ -62,6 +62,10 @@ open class FakeMusicRepository : MusicRepository {
throw NotImplementedError()
}
override fun renamePlaylist(playlist: Playlist, name: String) {
throw NotImplementedError()
}
override fun deletePlaylist(playlist: Playlist) {
throw NotImplementedError()
}
@ -70,7 +74,7 @@ open class FakeMusicRepository : MusicRepository {
throw NotImplementedError()
}
override fun renamePlaylist(playlist: Playlist, name: String) {
override fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
throw NotImplementedError()
}

View file

@ -60,7 +60,4 @@ open class FakeMusicSettings : MusicSettings {
override var genreSongSort: Sort
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
override var playlistSongSort: Sort
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
}