playback: add smooth seeking

Switch position math to rely on deciseconds (1/10th of a second)
instead of full seconds.

This makes seeking and position management much smoother, with minimal
performance cost. In the future I may try to migrate the playback state
so that the position calculations are done on the UI end, but this
works for now.
This commit is contained in:
Alexander Capehart 2022-08-29 09:44:31 -06:00
parent 1ed6d75121
commit 13793fdfe2
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 99 additions and 63 deletions

View file

@ -2,6 +2,12 @@
## dev
#### What's New
- Made the SeekBar much smoother and better to use
#### What's Fixed
- Fixed issue where fast scroller popup would not appear
## 2.6.2
#### What's New

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
/**
* A dialog displayed when "View properties" is selected on a song, showing more information about
@ -70,7 +70,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
binding.detailFormat.setText(song.info.resolvedMimeType.resolveName(context))
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
binding.detailDuration.setText(song.song.durationSecs.formatDuration(true))
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
if (song.info.bitrateKbps != null) {
binding.detailBitrate.setText(

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
@ -131,7 +131,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
val songCount = context.getPlural(R.plurals.fmt_song_count, item.songs.size)
val duration = item.durationSecs.formatDuration(true)
val duration = item.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
@ -157,7 +157,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
oldItem.artist.rawName == newItem.artist.rawName &&
oldItem.date == newItem.date &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationSecs == newItem.durationSecs &&
oldItem.durationMs == newItem.durationMs &&
oldItem.releaseType == newItem.releaseType
}
}
@ -207,7 +207,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
}
binding.songName.text = item.resolveName(binding.context)
binding.songDuration.text = item.durationSecs.formatDuration(false)
binding.songDuration.text = item.durationMs.formatDurationMs(false)
// binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) }
binding.root.setOnLongClickListener {

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.ui.recycler.SongViewHolder
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
@ -106,7 +106,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
binding.detailName.text = item.resolveName(binding.context)
binding.detailSubhead.text =
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
binding.detailInfo.text = item.durationSecs.formatDuration(false)
binding.detailInfo.text = item.durationMs.formatDurationMs(false)
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
}
@ -126,7 +126,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationSecs == newItem.durationSecs
oldItem.durationMs == newItem.durationMs
}
}
}

View file

@ -33,8 +33,9 @@ import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.MonoAdapter
import org.oxycblt.auxio.ui.recycler.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.logEOrThrow
import org.oxycblt.auxio.util.secsToMs
/**
* A [HomeListFragment] for showing a list of [Album]s.
@ -71,14 +72,14 @@ class AlbumListFragment : HomeListFragment<Album>() {
is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationSecs.formatDuration(false)
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
// Count -> Use song count
is Sort.Mode.ByCount -> album.songs.size.toString()
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.dateAdded * 1000
val dateAddedMillis = album.dateAdded.secsToMs()
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.MonoAdapter
import org.oxycblt.auxio.ui.recycler.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.logEOrThrow
/**
@ -61,7 +61,7 @@ class ArtistListFragment : HomeListFragment<Artist>() {
is Sort.Mode.ByName -> artist.sortName?.run { first().uppercase() }
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationSecs.formatDuration(false)
is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false)
// Count -> Use song count
is Sort.Mode.ByCount -> artist.songs.size.toString()

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.MonoAdapter
import org.oxycblt.auxio.ui.recycler.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.logEOrThrow
/**
@ -61,7 +61,7 @@ class GenreListFragment : HomeListFragment<Genre>() {
is Sort.Mode.ByName -> genre.sortName?.run { first().uppercase() }
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationSecs.formatDuration(false)
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
// Count -> Use song count
is Sort.Mode.ByCount -> genre.songs.size.toString()

View file

@ -34,8 +34,9 @@ import org.oxycblt.auxio.ui.recycler.SongViewHolder
import org.oxycblt.auxio.ui.recycler.SyncBackingData
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.logEOrThrow
import org.oxycblt.auxio.util.secsToMs
/**
* A [HomeListFragment] for showing a list of [Song]s.
@ -78,11 +79,11 @@ class SongListFragment : HomeListFragment<Song>() {
is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationSecs.formatDuration(false)
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.dateAdded * 1000
val dateAddedMillis = song.dateAdded.secsToMs()
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,

View file

@ -62,10 +62,6 @@ sealed class Music : Item() {
sealed class MusicParent : Music() {
/** The songs that this parent owns. */
abstract val songs: List<Song>
/** The formatted total duration of this genre */
val durationSecs: Long
get() = songs.sumOf { it.durationSecs }
}
/** The data object for a song. */
@ -122,9 +118,6 @@ data class Song(
override fun resolveName(context: Context) = rawName
/** The duration of this song, in seconds (rounded down) */
val durationSecs = durationMs / 1000
private var _album: Album? = null
/** The album of this song. */
val album: Album
@ -236,6 +229,9 @@ data class Album(
/** The earliest date a song in this album was added. */
val dateAdded = songs.minOf { it.dateAdded }
val durationMs: Long
get() = songs.sumOf { it.durationMs }
/** Internal field. Do not use. */
val _artistGroupingId: Long
get() = _artistGroupingName.toMusicId()
@ -273,6 +269,9 @@ data class Artist(
/** The songs of this artist. */
override val songs = albums.flatMap { it.songs }
val durationMs: Long
get() = songs.sumOf { it.durationMs }
}
/** The data object for a genre. */
@ -291,6 +290,9 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
get() = rawName.toMusicId()
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
val durationMs: Long
get() = songs.sumOf { it.durationMs }
}
private fun String?.toMusicId(): Long {

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
@ -33,6 +34,7 @@ import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.msToDs
/**
* A fragment showing the current playback state in a compact manner. Used as the bar for the
@ -104,7 +106,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.isPlaying, ::updateIsPlaying)
collectImmediately(playbackModel.positionSecs, ::updatePosition)
collectImmediately(playbackModel.positionDs, ::updatePosition)
}
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
@ -119,7 +121,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context)
binding.playbackInfo.text = song.resolveIndividualArtistName(context)
binding.playbackProgressBar.max = song.durationSecs.toInt()
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
}
}
@ -138,8 +140,8 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
requireBinding().playbackSecondaryAction.isActivated = isShuffled
}
private fun updatePosition(position: Long) {
requireBinding().playbackProgressBar.progress = position.toInt()
private fun updatePosition(positionDs: Long) {
requireBinding().playbackProgressBar.progress = positionDs.toInt()
}
}

View file

@ -35,6 +35,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.msToDs
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -109,7 +110,7 @@ class PlaybackPanelFragment :
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.parent, ::updateParent)
collectImmediately(playbackModel.positionSecs, ::updatePosition)
collectImmediately(playbackModel.positionDs, ::updatePosition)
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
@ -142,8 +143,8 @@ class PlaybackPanelFragment :
}
}
override fun seekTo(positionSecs: Long) {
playbackModel.seekTo(positionSecs)
override fun seekTo(positionDs: Long) {
playbackModel.seekTo(positionDs)
}
private fun updateSong(song: Song?) {
@ -155,7 +156,7 @@ class PlaybackPanelFragment :
binding.playbackSong.text = song.resolveName(context)
binding.playbackArtist.text = song.resolveIndividualArtistName(context)
binding.playbackAlbum.text = song.album.resolveName(context)
binding.playbackSeekBar.durationSecs = song.durationSecs
binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
}
private fun updateParent(parent: MusicParent?) {
@ -166,8 +167,8 @@ class PlaybackPanelFragment :
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
}
private fun updatePosition(positionSecs: Long) {
requireBinding().playbackSeekBar.positionSecs = positionSecs
private fun updatePosition(positionDs: Long) {
requireBinding().playbackSeekBar.positionDs = positionDs
}
private fun updateRepeat(repeatMode: RepeatMode) {

View file

@ -35,7 +35,9 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.dsToMs
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.msToDs
/**
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
@ -60,10 +62,10 @@ class PlaybackViewModel(application: Application) :
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean>
get() = _isPlaying
private val _positionSecs = MutableStateFlow(0L)
/** The current playback position, in seconds */
val positionSecs: StateFlow<Long>
get() = _positionSecs
private val _positionDs = MutableStateFlow(0L)
/** The current playback position, in *deci-seconds* */
val positionDs: StateFlow<Long>
get() = _positionDs
private val _repeatMode = MutableStateFlow(RepeatMode.NONE)
/** The current repeat mode, see [RepeatMode] for more information */
@ -147,8 +149,8 @@ class PlaybackViewModel(application: Application) :
// --- PLAYER FUNCTIONS ---
/** Update the position and push it to [PlaybackStateManager] */
fun seekTo(positionSecs: Long) {
playbackManager.seekTo(positionSecs * 1000)
fun seekTo(positionDs: Long) {
playbackManager.seekTo(positionDs.dsToMs())
}
// --- QUEUE FUNCTIONS ---
@ -267,7 +269,7 @@ class PlaybackViewModel(application: Application) :
}
override fun onPositionChanged(positionMs: Long) {
_positionSecs.value = positionMs / 1000
_positionDs.value = positionMs.msToDs()
}
override fun onPlayingChanged(isPlaying: Boolean) {

View file

@ -22,7 +22,7 @@ import android.util.AttributeSet
import com.google.android.material.slider.Slider
import kotlin.math.max
import org.oxycblt.auxio.databinding.ViewSeekBarBinding
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationDs
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
@ -66,18 +66,18 @@ constructor(
* The current position, in seconds. This is the current value of the SeekBar and is indicated
* by the start TextView in the layout.
*/
var positionSecs: Long
var positionDs: Long
get() = binding.seekBarSlider.value.toLong()
set(value) {
// Sanity check: Ensure that this value is within the duration and will not crash
// the app, and that the user is not currently seeking (which would cause the SeekBar
// to jump around).
if (value <= durationSecs && !isActivated) {
if (value <= durationDs && !isActivated) {
binding.seekBarSlider.value = value.toFloat()
// We would want to keep this in the callback, but the callback only fires when
// a value changes completely, and sometimes that does not happen with this view.
binding.seekBarPosition.text = value.formatDuration(true)
binding.seekBarPosition.text = value.formatDurationDs(true)
}
}
@ -85,7 +85,7 @@ constructor(
* The current duration, in seconds. This is the end value of the SeekBar and is indicated by
* the end TextView in the layout.
*/
var durationSecs: Long
var durationDs: Long
get() = binding.seekBarSlider.valueTo.toLong()
set(value) {
// Sanity check 1: If this is a value so low that it effectively rounds down to
@ -95,13 +95,13 @@ constructor(
// Sanity check 2: If the current value exceeds the new duration value, clamp it
// down so that we don't crash and instead have an annoying visual flicker.
if (positionSecs > to) {
logD("Clamping invalid position [current: $positionSecs new max: $to]")
if (positionDs > to) {
logD("Clamping invalid position [current: $positionDs new max: $to]")
binding.seekBarSlider.value = to.toFloat()
}
binding.seekBarSlider.valueTo = to.toFloat()
binding.seekBarDuration.text = value.formatDuration(false)
binding.seekBarDuration.text = value.formatDurationDs(false)
}
override fun onStartTrackingTouch(slider: Slider) {
@ -119,13 +119,13 @@ constructor(
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
binding.seekBarPosition.text = value.toLong().formatDuration(true)
binding.seekBarPosition.text = value.toLong().formatDurationDs(true)
}
interface Callback {
/**
* Called when a seek event was completed and the new position must be seeked to by the app.
*/
fun seekTo(positionSecs: Long)
fun seekTo(positionDs: Long)
}
}

View file

@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -84,7 +84,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
binding.aboutTotalDuration.text =
getString(
R.string.fmt_lib_total_duration,
songs.sumOf { it.durationSecs }.formatDuration(false))
songs.sumOf { it.durationMs }.formatDurationMs(false))
}
private fun updateAlbumCount(albums: List<Album>) {

View file

@ -205,23 +205,20 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(ascending) { it.durationSecs },
compareBy(BasicComparator.SONG))
compareByDynamic(ascending) { it.durationMs }, compareBy(BasicComparator.SONG))
override fun getAlbumComparator(ascending: Boolean): Comparator<Album> =
MultiComparator(
compareByDynamic(ascending) { it.durationSecs },
compareBy(BasicComparator.ALBUM))
compareByDynamic(ascending) { it.durationMs }, compareBy(BasicComparator.ALBUM))
override fun getArtistComparator(ascending: Boolean): Comparator<Artist> =
MultiComparator(
compareByDynamic(ascending) { it.durationSecs },
compareByDynamic(ascending) { it.durationMs },
compareBy(BasicComparator.ARTIST))
override fun getGenreComparator(ascending: Boolean): Comparator<Genre> =
MultiComparator(
compareByDynamic(ascending) { it.durationSecs },
compareBy(BasicComparator.GENRE))
compareByDynamic(ascending) { it.durationMs }, compareBy(BasicComparator.GENRE))
}
/** Sort by the amount of songs. Only applicable to music parents. */

View file

@ -47,12 +47,36 @@ fun Int.nonZeroOrNull() = if (this > 0) this else null
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
fun Long.msToDs() = floorDiv(100)
fun Long.msToSecs() = floorDiv(1000)
fun Long.dsToMs() = times(100)
fun Long.dsToSecs() = floorDiv(10)
fun Long.secsToMs() = times(1000)
/**
* Convert a [Long] of milliseconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0.
*/
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed)
/**
* Convert a [Long] of deci-seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0.
*/
fun Long.formatDurationDs(isElapsed: Boolean) = dsToSecs().formatDurationSecs(isElapsed)
/**
* Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0.
*/
fun Long.formatDuration(isElapsed: Boolean): String {
fun Long.formatDurationSecs(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) {
logD("Non-elapsed duration is zero, using --:--")
return "--:--"