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:
parent
33381f463a
commit
996c86b361
27 changed files with 634 additions and 158 deletions
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()}"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
11
app/src/main/res/drawable/ic_edit_24.xml
Normal file
11
app/src/main/res/drawable/ic_edit_24.xml
Normal 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>
|
50
app/src/main/res/layout/item_edit_header.xml
Normal file
50
app/src/main/res/layout/item_edit_header.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue