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
|
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 */
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
||||||
if (playlist.songs.isNotEmpty()) {
|
val newInstructions =
|
||||||
val header = BasicHeader(R.string.lbl_songs)
|
if (playlist.songs.isNotEmpty()) {
|
||||||
list.add(Divider(header))
|
val header = EditHeader(R.string.lbl_songs)
|
||||||
list.add(header)
|
list.add(Divider(header))
|
||||||
if (replace) {
|
list.add(header)
|
||||||
instructions = UpdateInstructions.Replace(list.size)
|
list.addAll(_editedPlaylist.value ?: playlist.songs)
|
||||||
|
instructions
|
||||||
|
} else {
|
||||||
|
UpdateInstructions.Diff
|
||||||
}
|
}
|
||||||
list.addAll(playlistSongSort.songs(playlist.songs))
|
|
||||||
}
|
_playlistInstructions.put(newInstructions)
|
||||||
_playlistInstructions.put(instructions)
|
|
||||||
_playlistList.value = list
|
_playlistList.value = list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
override fun onDropEdit() {
|
||||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
detailModel.dropPlaylistEdit()
|
||||||
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)))
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
}
|
throw IllegalStateException()
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()}"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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() }
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
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_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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue