music: clean up playlist experience

Add a variety of mild fixes and qol improvements regarding playlists.
This commit is contained in:
Alexander Capehart 2023-05-20 11:23:16 -06:00
parent 1fd6795b0d
commit 572b0e52f8
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
21 changed files with 89 additions and 37 deletions

View file

@ -348,7 +348,6 @@ constructor(
* @param at The position of the item to remove, in the list adapter data. * @param at The position of the item to remove, in the list adapter data.
*/ */
fun removePlaylistSong(at: Int) { fun removePlaylistSong(at: Int) {
// TODO: Remove header when empty
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2 val realAt = at - 2
@ -357,7 +356,13 @@ constructor(
} }
editedPlaylist.removeAt(realAt) editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Remove(at)) refreshPlaylistList(
playlist,
if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1)
} else {
UpdateInstructions.Remove(at - 2, 3)
})
} }
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
@ -490,18 +495,15 @@ constructor(
logD("Refreshing playlist list") logD("Refreshing playlist list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
val newInstructions = val songs = editedPlaylist.value ?: playlist.songs
if (playlist.songs.isNotEmpty()) { if (songs.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs) val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header)) list.add(Divider(header))
list.add(header) list.add(header)
list.addAll(_editedPlaylist.value ?: playlist.songs) list.addAll(songs)
instructions }
} else {
UpdateInstructions.Diff
}
_playlistInstructions.put(newInstructions) _playlistInstructions.put(instructions)
_playlistList.value = list _playlistList.value = list
} }

View file

@ -211,8 +211,7 @@ class PlaylistDetailFragment :
} }
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_playlist_song_actions, item)
openMusicMenu(anchor, R.menu.menu_song_actions, item)
} }
override fun onPlay() { override fun onPlay() {
@ -284,9 +283,11 @@ class PlaylistDetailFragment :
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
selectionModel.drop() selectionModel.drop()
logD(editedPlaylist == detailModel.currentPlaylist.value?.songs) if (editedPlaylist != null) {
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).isEnabled = requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
editedPlaylist != detailModel.currentPlaylist.value?.songs isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
}
}
updateMultiToolbar() updateMultiToolbar()
} }

View file

@ -83,6 +83,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
editedPlaylist: List<Song>?, editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener listener: DetailHeaderAdapter.Listener
) { ) {
// TODO: Debug perpetually re-binding images
binding.detailCover.bind(playlist, editedPlaylist) binding.detailCover.bind(playlist, editedPlaylist)
binding.detailType.text = binding.context.getString(R.string.lbl_playlist) binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context) binding.detailName.text = playlist.name.resolve(binding.context)

View file

@ -99,7 +99,7 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
return return
} }
this.isEditing = editing this.isEditing = editing
notifyItemRangeChanged(1, currentList.size - 2, PAYLOAD_EDITING_CHANGED) notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
} }
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */ /** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
@ -256,6 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {
binding.interactBody.isActivated = isSelected
binding.songAlbumCover.isActivated = isSelected binding.songAlbumCover.isActivated = isSelected
} }

View file

@ -44,6 +44,13 @@ import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/**
* A [ListFragment] that shows a list of [Playlist]s.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Show a placeholder when there are no playlists.
*/
class PlaylistListFragment : class PlaylistListFragment :
ListFragment<Playlist, FragmentHomeListBinding>(), ListFragment<Playlist, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.PopupProvider,

View file

@ -93,8 +93,9 @@ sealed interface UpdateInstructions {
* Remove an item. * Remove an item.
* *
* @param at The location that the item should be removed from. * @param at The location that the item should be removed from.
* @param size The amount of items to add.
*/ */
data class Remove(val at: Int) : UpdateInstructions data class Remove(val at: Int, val size: Int) : UpdateInstructions
} }
/** /**
@ -147,7 +148,7 @@ private class FlexibleListDiffer<T>(
} }
is UpdateInstructions.Remove -> { is UpdateInstructions.Remove -> {
currentList = newList currentList = newList
updateCallback.onRemoved(instructions.at, 1) updateCallback.onRemoved(instructions.at, instructions.size)
callback?.invoke() callback?.invoke()
} }
is UpdateInstructions.Diff, is UpdateInstructions.Diff,

View file

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

View file

@ -34,6 +34,7 @@ import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.MainNavigationAction
@ -50,6 +51,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* available controls. * available controls.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Improve flickering situation on play button
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class PlaybackPanelFragment : class PlaybackPanelFragment :
@ -57,6 +60,7 @@ class PlaybackPanelFragment :
Toolbar.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener,
StyledSeekBar.Listener { StyledSeekBar.Listener {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
@ -164,6 +168,10 @@ class PlaybackPanelFragment :
navigateToCurrentAlbum() navigateToCurrentAlbum()
true true
} }
R.id.action_playlist_add -> {
playbackModel.song.value?.let(musicModel::addToPlaylist)
true
}
R.id.action_song_detail -> { R.id.action_song_detail -> {
playbackModel.song.value?.let { song -> playbackModel.song.value?.let { song ->
navModel.mainNavigateTo( navModel.mainNavigateTo(

View file

@ -306,7 +306,7 @@ class EditableQueue : Queue {
else -> Queue.Change.Type.MAPPING else -> Queue.Change.Type.MAPPING
} }
check() check()
return Queue.Change(type, UpdateInstructions.Remove(at)) return Queue.Change(type, UpdateInstructions.Remove(at, 1))
} }
/** /**

View file

@ -534,17 +534,24 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
val internalPlayer = internalPlayer ?: return val internalPlayer = internalPlayer ?: return
logD("Restoring state $savedState") logD("Restoring state $savedState")
val lastSong = queue.currentSong
parent = savedState.parent parent = savedState.parent
queue.applySavedState(savedState.queueState) queue.applySavedState(savedState.queueState)
repeatMode = savedState.repeatMode repeatMode = savedState.repeatMode
notifyNewPlayback() notifyNewPlayback()
// Continuing playback while also possibly doing drastic state updates is // Check if we need to reload the player with a new music file, or if we can just leave
// a bad idea, so pause. // it be. Specifically done so we don't pause on music updates that don't really change
internalPlayer.loadSong(queue.currentSong, false) // what's playing (ex. playlist editing)
if (queue.currentSong != null) { if (lastSong != queue.currentSong) {
// Internal player may have reloaded the media item, re-seek to the previous position // Continuing playback while also possibly doing drastic state updates is
seekTo(savedState.positionMs) // a bad idea, so pause.
internalPlayer.loadSong(queue.currentSong, false)
if (queue.currentSong != null) {
// Internal player may have reloaded the media item, re-seek to the previous
// position
seekTo(savedState.positionMs)
}
} }
isInitialized = true isInitialized = true
} }

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorSurface" /> <item android:drawable="@drawable/sel_selection_bg" />
<item android:drawable="@drawable/sel_item_ripple_bg" />
<item android:drawable="?attr/selectableItemBackground" /> <item android:drawable="?attr/selectableItemBackground" />
</layer-list> </layer-list>

View file

@ -4,8 +4,9 @@
android:height="24dp" android:height="24dp"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960"
android:tint="?attr/colorControlNormal"> android:tint="@color/sel_activatable_icon">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z"/> android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z"/>
</vector> </vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorSurface" />
<item android:drawable="@drawable/ui_item_ripple" />
</layer-list>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorSurface" />
<item android:drawable="?attr/selectableItemBackground" /> <item android:drawable="?attr/selectableItemBackground" />
</layer-list> </layer-list>

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/ui_item_ripple" android:background="@drawable/ui_item_bg"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_mid_medium" android:paddingTop="@dimen/spacing_mid_medium"
android:paddingEnd="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium"

View file

@ -32,7 +32,7 @@
android:id="@+id/interact_body" android:id="@+id/interact_body"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"> android:background="@drawable/ui_item_ripple">
<org.oxycblt.auxio.image.ImageGroup <org.oxycblt.auxio.image.ImageGroup
android:id="@+id/song_album_cover" android:id="@+id/song_album_cover"

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/ui_item_ripple" android:background="@drawable/ui_item_bg"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_mid_medium" android:paddingTop="@dimen/spacing_mid_medium"
android:paddingEnd="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium"

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/ui_item_ripple" android:background="@drawable/ui_item_bg"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_mid_medium" android:paddingTop="@dimen/spacing_mid_medium"
android:paddingEnd="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium"

View file

@ -14,6 +14,9 @@
android:id="@+id/action_go_album" android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" android:title="@string/lbl_go_album"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
<item <item
android:id="@+id/action_song_detail" android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" android:title="@string/lbl_song_detail"

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play_next"
android:title="@string/lbl_play_next" />
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
<item
android:id="@+id/action_go_artist"
android:title="@string/lbl_go_artist" />
<item
android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />
</menu>