list: add update instructions framework

Add the basic framework that should allow for different types of list
updates in different situations.
This commit is contained in:
Alexander Capehart 2023-01-14 19:53:24 -07:00
parent 5988908b56
commit 176f0cc465
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 100 additions and 88 deletions

View file

@ -2,6 +2,9 @@
## dev
#### What's New
- Added ability to play/shuffle selections
#### What's Improved
- Added ability to edit previously played or currently playing items in the queue
- Added support for date values formatted as "YYYYMMDD"

View file

@ -343,10 +343,8 @@ class MainFragment :
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed
queueSheetBehavior?.isDraggable = true
playbackSheetBehavior.apply {
// Make sure the view is draggable, at least until the draw checks kick in.
isDraggable = true

View file

@ -0,0 +1,19 @@
package org.oxycblt.auxio.list
/**
* Represents the specific way to update a list of items.
* @author Alexander Capehart (OxygenCobalt)
*/
enum class UpdateInstructions {
/**
* (A)synchronously diff the list. This should be used for small diffs with little item
* movement.
*/
DIFF,
/**
* Synchronously remove the current list and replace it with a new one. This should be used
* for large diffs with that would cause erratic scroll behavior or in-efficiency.
*/
REPLACE
}

View file

@ -242,7 +242,7 @@ class PlaybackViewModel(application: Application) :
* @param selection The selection to play.
*/
fun play(selection: List<Music>) =
playbackManager.play(null, selectionToSongs(selection), false)
playbackManager.play(null, null, selectionToSongs(selection), false)
/**
* Shuffle an [Album].
@ -267,7 +267,7 @@ class PlaybackViewModel(application: Application) :
* @param selection The selection to shuffle.
*/
fun shuffle(selection: List<Music>) =
playbackManager.play(null, selectionToSongs(selection), true)
playbackManager.play(null, null, selectionToSongs(selection), true)
private fun playImpl(
song: Song?,
@ -286,7 +286,7 @@ class PlaybackViewModel(application: Application) :
null -> musicSettings.songSort
}
val queue = sort.songs(parent?.songs ?: library.songs)
playbackManager.play(song, queue, shuffled)
playbackManager.play(song, parent, queue, shuffled)
}
/**

View file

@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.UpdateInstructions
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
@ -100,18 +101,19 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
val binding = requireBinding()
// Replace or diff the queue depending on the type of change it is.
// TODO: Extend this to the whole app.
if (queueModel.replaceQueue == true) {
val instructions = queueModel.instructions
if (instructions?.update == UpdateInstructions.REPLACE) {
logD("Replacing queue")
queueAdapter.replaceList(queue)
} else {
logD("Diffing queue")
queueAdapter.submitList(queue)
}
queueModel.finishReplace()
// Update position in list (and thus past/future items)
queueAdapter.setPosition(index, isPlaying)
// If requested, scroll to a new item (occurs when the index moves)
val scrollTo = queueModel.scrollTo
val scrollTo = instructions?.scrollTo
if (scrollTo != null) {
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition()
@ -132,9 +134,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
min(queue.lastIndex, scrollTo + (end - start)))
}
}
queueModel.finishScrollTo()
// Update position in list (and thus past/future items)
queueAdapter.setPosition(index, isPlaying)
queueModel.finishInstructions()
}
}

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.queue
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.UpdateInstructions
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
@ -42,15 +43,47 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
val index: StateFlow<Int>
get() = _index
/** Whether to replace or diff the queue list when updating it. Is null if not specified. */
var replaceQueue: Boolean? = null
/** Flag to scroll to a particular queue item. Is null if no command has been specified. */
var scrollTo: Int? = null
/** Specifies how to update the list when the queue changes. */
var instructions: Instructions? = null
init {
playbackManager.addListener(this)
}
override fun onIndexMoved(queue: Queue) {
instructions = Instructions(null, queue.index)
_index.value = queue.index
}
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
// Queue changed trivially due to item mo -> Diff queue, stay at current index.
instructions = Instructions(UpdateInstructions.DIFF, null)
_queue.value = queue.resolve()
if (change != Queue.ChangeResult.MAPPING) {
// Index changed, make sure it remains updated without actually scrolling to it.
_index.value = queue.index
}
}
override fun onQueueReordered(queue: Queue) {
// Queue changed completely -> Replace queue, update index
instructions = Instructions(UpdateInstructions.REPLACE, null)
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
instructions = Instructions(UpdateInstructions.REPLACE, null)
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onCleared() {
super.onCleared()
playbackManager.removeListener(this)
}
/**
* Start playing the the queue item at the given index.
* @param adapterIndex The index of the queue item to play. Does nothing if the index is out of
@ -86,52 +119,10 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
return true
}
/** Finish a replace flag specified by [replaceQueue]. */
fun finishReplace() {
replaceQueue = null
/** Signal that the specified [Instructions] in [instructions] were performed. */
fun finishInstructions() {
instructions = null
}
/** Finish a scroll operation started by [scrollTo]. */
fun finishScrollTo() {
scrollTo = null
}
override fun onIndexMoved(queue: Queue) {
// Index moved -> Scroll to new index
replaceQueue = null
scrollTo = queue.index
_index.value = queue.index
}
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
// Queue changed trivially due to item mo -> Diff queue, stay at current index.
replaceQueue = false
scrollTo = null
_queue.value = queue.resolve()
if (change != Queue.ChangeResult.MAPPING) {
// Index changed, make sure it remains updated without actually scrolling to it.
_index.value = queue.index
}
}
override fun onQueueReordered(queue: Queue) {
// Queue changed completely -> Replace queue, update index
replaceQueue = true
scrollTo = queue.index
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
replaceQueue = true
scrollTo = queue.index
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onCleared() {
super.onCleared()
playbackManager.removeListener(this)
}
class Instructions(val update: UpdateInstructions?, val scrollTo: Int?)
}

View file

@ -23,7 +23,6 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@ -60,7 +59,7 @@ class PlaybackStateManager private constructor() {
val queue = Queue()
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
@Volatile
var parent: MusicParent? = null
var parent: MusicParent? = null // TODO: Parent is interpreted wrong when nothing is playing.
private set
/** The current [InternalPlayer] state. */
@ -98,7 +97,7 @@ class PlaybackStateManager private constructor() {
}
/**
* Remove a [Listener] from this instance, preventing it from recieving any further updates.
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place.
* @see Listener
@ -156,14 +155,13 @@ class PlaybackStateManager private constructor() {
* Start new playback.
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
* @param queue The queue of [Song]s to play from.
* @param parent The [MusicParent] to play from, or null if to play from the entire [Library].
* @param sort [Sort] to initially sort an ordered queue with.
* @param parent The [MusicParent] to play from, or null if to play from an non-specific
* collection of "All [Song]s".
* @param shuffled Whether to shuffle or not.
*/
@Synchronized
fun play(song: Song?, queue: List<Song>, shuffled: Boolean) {
fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return
// Set up parent and queue
this.parent = parent
this.queue.start(song, queue, shuffled)

View file

@ -208,8 +208,9 @@ class Queue {
// We have moved an song from in front of the playing song to behind, shift forward.
in dst until src -> index += 1
else -> {
// Nothing to do.
check()
ChangeResult.MAPPING
return ChangeResult.MAPPING
}
}
check()

View file

@ -355,13 +355,14 @@ class PlaybackService :
}
// Shuffle all -> Start new playback from all songs
is InternalPlayer.Action.ShuffleAll -> {
playbackManager.play(null, musicSettings.songSort.songs(library.songs), true)
playbackManager.play(null, null, musicSettings.songSort.songs(library.songs), true)
}
// Open -> Try to find the Song for the given file and then play it from all songs
is InternalPlayer.Action.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(
song,
null,
musicSettings.songSort.songs(library.songs),
playbackManager.queue.isShuffled && playbackSettings.keepShuffle)
}

View file

@ -123,8 +123,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
if (pkgName == "android") {
// No default browser [Must open app chooser, may not be supported]
openAppChooser(browserIntent)
} else {
try {
} else try {
browserIntent.setPackage(pkgName)
startActivity(browserIntent)
} catch (e: ActivityNotFoundException) {
@ -132,7 +131,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
browserIntent.setPackage(null)
openAppChooser(browserIntent)
}
}
} else {
// No app installed to open the link
context.showToast(R.string.err_no_app)

View file

@ -88,6 +88,11 @@ interface Settings<L> {
onSettingChanged(key, unlikelyToBeNull(listener))
}
open fun onSettingChanged(key: String, listener: L) {}
/**
* Called when a setting entry with the given [key] has changed.
* @param key The key of the changed setting.
* @param listener The implementation's listener that updates should be applied to.
*/
protected open fun onSettingChanged(key: String, listener: L) {}
}
}

View file

@ -56,7 +56,7 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
/**
* Called when window insets are being applied to the [View] this [BaseBottomSheetBehavior] is
* linked to.
* @param child The child view recieving the [WindowInsets].
* @param child The child view receiving the [WindowInsets].
* @param insets The [WindowInsets] to apply.
* @return The (possibly modified) [WindowInsets].
* @see View.onApplyWindowInsets

View file

@ -74,12 +74,11 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
* @return The currently-inflated [ViewBinding].
* @throws IllegalStateException if the [ViewBinding] is not inflated.
*/
protected fun requireBinding(): VB {
return requireNotNull(_binding) {
protected fun requireBinding() =
requireNotNull(_binding) {
"ViewBinding was available. Fragment should be a valid state " +
"right now, but instead it was ${lifecycle.currentState}"
}
}
final override fun onCreateView(
inflater: LayoutInflater,

View file

@ -65,12 +65,11 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
* @return The currently-inflated [ViewBinding].
* @throws IllegalStateException if the [ViewBinding] is not inflated.
*/
protected fun requireBinding(): VB {
return requireNotNull(_binding) {
protected fun requireBinding() =
requireNotNull(_binding) {
"ViewBinding was available. Fragment should be a valid state " +
"right now, but instead it was ${lifecycle.currentState}"
}
}
final override fun onCreateView(
inflater: LayoutInflater,