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 const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */ /** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00B 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 */ /** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */ /** "Music loading" notification code */

View file

@ -22,6 +22,7 @@ import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.lang.Exception
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Divider
@ -145,6 +147,7 @@ constructor(
} }
// --- PLAYLIST --- // --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null) private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */ /** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?> val currentPlaylist: StateFlow<Playlist?>
@ -158,16 +161,13 @@ constructor(
val playlistInstructions: Event<UpdateInstructions> val playlistInstructions: Event<UpdateInstructions>
get() = _playlistInstructions get() = _playlistInstructions
private var isEditingPlaylist = false private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/**
/** The current [Sort] used for [Song]s in [playlistList]. */ * The new playlist songs created during the current editing session. Null if no editing session
var playlistSongSort: Sort * is occurring.
get() = musicSettings.playlistSongSort */
set(value) { val editedPlaylist: StateFlow<List<Song>?>
musicSettings.playlistSongSort = value get() = _editedPlaylist
// 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 * 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) { if (changes.userLibrary && userLibrary != null) {
val playlist = currentPlaylist.value val playlist = currentPlaylist.value
if (playlist != null) { if (playlist != null) {
logD("Updated playlist to ${currentPlaylist.value}")
_currentPlaylist.value = _currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
} }
@ -285,6 +286,71 @@ constructor(
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) 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) { private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI. // Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel() currentSongJob?.cancel()
@ -408,21 +474,26 @@ constructor(
_genreList.value = list _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") logD("Refreshing playlist list")
var instructions: UpdateInstructions = UpdateInstructions.Diff
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
val newInstructions =
if (playlist.songs.isNotEmpty()) { if (playlist.songs.isNotEmpty()) {
val header = BasicHeader(R.string.lbl_songs) val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header)) list.add(Divider(header))
list.add(header) list.add(header)
if (replace) { list.addAll(_editedPlaylist.value ?: playlist.songs)
instructions = UpdateInstructions.Replace(list.size) instructions
} else {
UpdateInstructions.Diff
} }
list.addAll(playlistSongSort.songs(playlist.songs))
} _playlistInstructions.put(newInstructions)
_playlistInstructions.put(instructions)
_playlistList.value = list _playlistList.value = list
} }

View file

@ -23,23 +23,26 @@ import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter 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.PlaylistDetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
@ -55,7 +58,8 @@ import org.oxycblt.auxio.util.*
class PlaylistDetailFragment : class PlaylistDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), ListFragment<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener, DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> { PlaylistDetailListAdapter.Listener,
NavController.OnDestinationChangedListener {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
@ -66,6 +70,8 @@ class PlaylistDetailFragment :
private val args: PlaylistDetailFragmentArgs by navArgs() private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this) private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var initialNavDestinationChange = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -92,6 +98,10 @@ class PlaylistDetailFragment :
binding.detailRecycler.apply { binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(this)
}
(layoutManager as GridLayoutManager).setFullWidthLookup { (layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) { if (it != 0) {
val item = detailModel.playlistList.value[it - 1] val item = detailModel.playlistList.value[it - 1]
@ -107,21 +117,52 @@ class PlaylistDetailFragment :
detailModel.setPlaylistUid(args.playlistUid) detailModel.setPlaylistUid(args.playlistUid)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistList, ::updateList) collectImmediately(detailModel.playlistList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedPlaylist)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation) collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection) 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) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
touchHelper = null
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed // 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. // during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.playlistInstructions.consume() 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 { override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) { if (super.onMenuItemClick(item)) {
return true return true
@ -155,7 +196,12 @@ class PlaylistDetailFragment :
playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value)) 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) { override fun onOpenMenu(item: Song, anchor: View) {
// TODO: Remove "Add to playlist" option, makes no sense
openMusicMenu(anchor, R.menu.menu_song_actions, item) openMusicMenu(anchor, R.menu.menu_song_actions, item)
} }
@ -167,39 +213,21 @@ class PlaylistDetailFragment :
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
} }
override fun onOpenSortMenu(anchor: View) { override fun onStartEdit() {
openMenu(anchor, R.menu.menu_playlist_sort) { selectionModel.drop()
// Select the corresponding sort mode option detailModel.startPlaylistEdit()
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 -> override fun onConfirmEdit() {
item.isChecked = !item.isChecked detailModel.confirmPlaylistEdit()
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 onDropEdit() {
detailModel.dropPlaylistEdit()
} }
override fun onOpenSortMenu(anchor: View) {
throw IllegalStateException()
} }
private fun updatePlaylist(playlist: Playlist?) { private fun updatePlaylist(playlist: Playlist?) {
@ -250,6 +278,12 @@ class PlaylistDetailFragment :
playlistListAdapter.update(list, detailModel.playlistInstructions.consume()) 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>) { private fun updateSelection(selected: List<Music>) {
playlistListAdapter.setSelected(selected.toSet()) playlistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)

View file

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

View file

@ -18,53 +18,286 @@
package org.oxycblt.auxio.detail.list package org.oxycblt.auxio.detail.list
import android.annotation.SuppressLint
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.view.ViewGroup 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 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.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.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song 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. * @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaylistDetailListAdapter(private val listener: Listener<Song>) : class PlaylistDetailListAdapter(private val listener: Listener) :
DetailListAdapter(listener, DIFF_CALLBACK) { DetailListAdapter(listener, DIFF_CALLBACK) {
private var isEditing = false
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Support generic song items. is EditHeader -> EditHeaderViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE is Song -> PlaylistSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
if (viewType == SongViewHolder.VIEW_TYPE) { when (viewType) {
SongViewHolder.from(parent) EditHeaderViewHolder.VIEW_TYPE -> EditHeaderViewHolder.from(parent)
} else { PlaylistSongViewHolder.VIEW_TYPE -> PlaylistSongViewHolder.from(parent)
super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(
super.onBindViewHolder(holder, position) holder: RecyclerView.ViewHolder,
val item = getItem(position) position: Int,
if (item is Song) { payloads: List<Any>
(holder as SongViewHolder).bind(item, listener) ) {
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)
} }
} }
companion object { if (holder is ViewHolder) {
holder.updateEditing(isEditing)
}
}
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 = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) = override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when { when {
oldItem is Song && newItem is Song -> 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) 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 androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding 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.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater 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. * 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>() { RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */ /** The current array of [Tab]s. */
var tabs = arrayOf<Tab>() var tabs = arrayOf<Tab>()
@ -97,10 +97,10 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* Bind new data to this instance. * Bind new data to this instance.
* *
* @param tab The new [Tab] to bind. * @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") @SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: EditableListListener<Tab>) { fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle) listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply { binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode // 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.R
import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings 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.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class TabCustomizeDialog : class TabCustomizeDialog :
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> { ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
private val tabAdapter = TabAdapter(this) private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null
@Inject lateinit var homeSettings: HomeSettings @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()}" 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> { class ParentKeyer @Inject constructor() : Keyer<MusicParent> {
override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}" 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface EditableListListener<in T> : ClickableListListener<T> { interface EditableListListener {
/** /**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged. * 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) 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. * Binds this instance to a list item.
* *
@ -78,13 +101,7 @@ interface EditableListListener<in T> : ClickableListListener<T> {
dragHandle: View dragHandle: View
) { ) {
bind(item, viewHolder, bodyView) bind(item, viewHolder, bodyView)
dragHandle.setOnTouchListener { _, motionEvent -> bind(viewHolder, dragHandle)
dragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
onPickUp(viewHolder)
true
} else false
}
} }
} }

View file

@ -206,19 +206,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/ */
fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = null 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. * Sort by the item's name.
* *
@ -455,7 +442,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/ */
fun fromIntCode(intCode: Int) = fun fromIntCode(intCode: Int) =
when (intCode) { when (intCode) {
ByNone.intCode -> ByNone
ByName.intCode -> ByName ByName.intCode -> ByName
ByArtist.intCode -> ByArtist ByArtist.intCode -> ByArtist
ByAlbum.intCode -> ByAlbum ByAlbum.intCode -> ByAlbum
@ -477,7 +463,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/ */
fun fromItemId(@IdRes itemId: Int) = fun fromItemId(@IdRes itemId: Int) =
when (itemId) { when (itemId) {
ByNone.itemId -> ByNone
ByName.itemId -> ByName ByName.itemId -> ByName
ByAlbum.itemId -> ByAlbum ByAlbum.itemId -> ByAlbum
ByArtist.itemId -> ByArtist ByArtist.itemId -> ByArtist

View file

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

View file

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

View file

@ -141,6 +141,14 @@ interface MusicRepository {
*/ */
fun addToPlaylist(songs: List<Song>, playlist: Playlist) 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 * Request that a music loading operation is started by the current [IndexingWorker]. Does
* nothing if one is not available. * 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) { override fun requestIndex(withCache: Boolean) {
indexingWorker?.requestIndex(withCache) indexingWorker?.requestIndex(withCache)
} }

View file

@ -63,8 +63,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
var artistSongSort: Sort var artistSongSort: Sort
/** The [Sort] mode used in a [Genre]'s [Song] list. */ /** The [Sort] mode used in a [Genre]'s [Song] list. */
var genreSongSort: Sort var genreSongSort: Sort
/** The [Sort] mode used in a [Playlist]'s [Song] list. */
var playlistSongSort: Sort
interface Listener { interface Listener {
/** Called when a setting controlling how music is loaded has changed. */ /** 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) { override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads" // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data) // (just need to manipulate data)

View file

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

View file

@ -101,6 +101,14 @@ interface MutableUserLibrary : UserLibrary {
* @param playlist The [Playlist] to add to. Must currently exist. * @param playlist The [Playlist] to add to. Must currently exist.
*/ */
fun addToPlaylist(playlist: Playlist, songs: List<Song>) 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 class UserLibraryFactoryImpl
@ -148,4 +156,11 @@ private class UserLibraryImpl(
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } 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" "Song to play not in parent"
} }
val deviceLibrary = musicRepository.deviceLibrary ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
val sort = val queue =
when (parent) { when (parent) {
is Genre -> musicSettings.genreSongSort is Genre -> musicSettings.genreSongSort.songs(parent.songs)
is Artist -> musicSettings.artistSongSort is Artist -> musicSettings.artistSongSort.songs(parent.songs)
is Album -> musicSettings.albumSongSort is Album -> musicSettings.albumSongSort.songs(parent.songs)
is Playlist -> musicSettings.playlistSongSort is Playlist -> parent.songs
null -> musicSettings.songSort null -> musicSettings.songSort.songs(deviceLibrary.songs)
} }
val songs = parent?.songs ?: deviceLibrary.songs
val queue = sort.songs(songs)
playbackManager.play(song, parent, queue, shuffled) playbackManager.play(song, parent, queue, shuffled)
} }
@ -394,7 +392,7 @@ constructor(
* @param playlist The [Playlist] to add. * @param playlist The [Playlist] to add.
*/ */
fun playNext(playlist: Playlist) { 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. * @param playlist The [Playlist] to add.
*/ */
fun addToQueue(playlist: Playlist) { 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 com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditableSongBinding 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.adapter.*
import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder 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. * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueAdapter(private val listener: EditableListListener<Song>) : class QueueAdapter(private val listener: EditClickListListener<Song>) :
FlexibleListAdapter<Song, QueueSongViewHolder>(QueueSongViewHolder.DIFF_CALLBACK) { FlexibleListAdapter<Song, QueueSongViewHolder>(QueueSongViewHolder.DIFF_CALLBACK) {
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this // 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 // 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 * A [PlayingIndicatorAdapter.ViewHolder] that displays an queue [Song] which can be re-ordered and
* and removed. Use [from] to create an instance. * removed. Use [from] to create an instance.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) : class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) :
PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder { PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder {
override val root: View override val enabled = true
get() = binding.root override val root = binding.root
override val body = binding.body
override val body: View override val delete = binding.background
get() = binding.body
override val delete: View
get() = binding.background
override val background = override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) 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. * Bind new data to this instance.
* *
* @param song The new [Song] to bind. * @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") @SuppressLint("ClickableViewAccessibility")
fun bind(song: Song, listener: EditableListListener<Song>) { fun bind(song: Song, listener: EditClickListListener<Song>) {
listener.bind(song, this, body, binding.songDragHandle) listener.bind(song, this, body, binding.songDragHandle)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.name.resolve(binding.context) 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 dagger.hilt.android.AndroidEntryPoint
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickListListener<Song> {
private val queueModel: QueueViewModel by activityViewModels() private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val queueAdapter = QueueAdapter(this) 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_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/song_album_cover" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout> </FrameLayout>

View file

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

View file

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

View file

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

View file

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