Skip to content

Commit 3a1a7ae

Browse files
committed
playback: introduce swipe to next
1 parent bf4c0b8 commit 3a1a7ae

17 files changed

Lines changed: 492 additions & 811 deletions

app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
6464
import org.oxycblt.auxio.ui.FadingToolbarOffsetListener
6565
import org.oxycblt.auxio.util.collect
6666
import org.oxycblt.auxio.util.collectImmediately
67+
import org.oxycblt.auxio.util.dampen
6768
import org.oxycblt.auxio.util.lazyReflectedField
6869
import org.oxycblt.auxio.util.navigateSafe
6970
import org.oxycblt.auxio.util.showToast
@@ -157,14 +158,7 @@ class HomeFragment : SelectionFragment<FragmentHomeBinding>() {
157158
// debug UI performance.
158159
offscreenPageLimit = Tab.MAX_SEQUENCE_IDX + 1
159160

160-
// By default, ViewPager2's sensitivity is high enough to result in vertical scroll
161-
// events being registered as horizontal scroll events. Reflect into the internal
162-
// RecyclerView and change the touch slope so that touch actions will act more as a
163-
// scroll than as a swipe. Derived from:
164-
// https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
165-
val recycler = VP_RECYCLER_FIELD.get(this@apply)
166-
val slop = RV_TOUCH_SLOP_FIELD.get(recycler) as Int
167-
RV_TOUCH_SLOP_FIELD.set(recycler, slop * 3)
161+
dampen()
168162
}
169163

170164
// Further initialization must be done in the function that also handles
@@ -499,9 +493,4 @@ class HomeFragment : SelectionFragment<FragmentHomeBinding>() {
499493
MusicType.PLAYLISTS -> PlaylistListFragment()
500494
}
501495
}
502-
503-
private companion object {
504-
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
505-
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
506-
}
507496
}

app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,38 @@ import android.media.audiofx.AudioEffect
2525
import android.os.Bundle
2626
import android.view.LayoutInflater
2727
import android.view.MenuItem
28-
import android.view.ViewTreeObserver
28+
import android.view.View
2929
import androidx.activity.result.ActivityResultLauncher
3030
import androidx.activity.result.contract.ActivityResultContracts
3131
import androidx.appcompat.widget.Toolbar
3232
import androidx.core.view.updatePadding
3333
import androidx.dynamicanimation.animation.SpringForce
3434
import androidx.fragment.app.activityViewModels
35+
import androidx.fragment.app.viewModels
3536
import dagger.hilt.android.AndroidEntryPoint
3637
import org.oxycblt.auxio.R
3738
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
3839
import org.oxycblt.auxio.detail.DetailViewModel
3940
import org.oxycblt.auxio.list.ListViewModel
4041
import org.oxycblt.auxio.music.resolve
4142
import org.oxycblt.auxio.music.resolveNames
43+
import org.oxycblt.auxio.playback.queue.QueueViewModel
4244
import org.oxycblt.auxio.playback.state.RepeatMode
4345
import org.oxycblt.auxio.playback.ui.StyledSeekBar
46+
import org.oxycblt.auxio.playback.ui.swiper.CarouselTransformer
47+
import org.oxycblt.auxio.playback.ui.swiper.CoverPagerAdapter
48+
import org.oxycblt.auxio.playback.ui.swiper.UserAwarePagerCallback
4449
import org.oxycblt.auxio.playback.ui.stepper.Direction
4550
import org.oxycblt.auxio.playback.ui.stepper.PlayerFastSeekOverlay
4651
import org.oxycblt.auxio.ui.ViewBindingFragment
4752
import org.oxycblt.auxio.util.collectImmediately
53+
import org.oxycblt.auxio.util.dampen
54+
import org.oxycblt.auxio.util.recycler
4855
import org.oxycblt.auxio.util.showToast
4956
import org.oxycblt.auxio.util.systemBarInsetsCompat
5057
import org.oxycblt.musikr.MusicParent
5158
import org.oxycblt.musikr.Song
59+
import kotlin.math.abs
5260
import timber.log.Timber as L
5361

5462
/**
@@ -64,13 +72,15 @@ class PlaybackPanelFragment :
6472
ViewBindingFragment<FragmentPlaybackPanelBinding>(),
6573
Toolbar.OnMenuItemClickListener,
6674
StyledSeekBar.Listener,
67-
ViewTreeObserver.OnGlobalLayoutListener,
6875
PlayerFastSeekOverlay.PerformListener {
76+
private val coverPagerAdapter = CoverPagerAdapter(this)
6977
private val playbackModel: PlaybackViewModel by activityViewModels()
7078
private val detailModel: DetailViewModel by activityViewModels()
7179
private val listModel: ListViewModel by activityViewModels()
80+
private val queueModel: QueueViewModel by viewModels()
7281
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
73-
private var lastCoverWidth = 0
82+
private var userAwarePagerCallback: UserAwarePagerCallback? = null
83+
private var currentPagerPosition = 0
7484

7585
override fun onCreateBinding(inflater: LayoutInflater) =
7686
FragmentPlaybackPanelBinding.inflate(inflater)
@@ -100,11 +110,27 @@ class PlaybackPanelFragment :
100110
setOnMenuItemClickListener(this@PlaybackPanelFragment)
101111
}
102112

103-
// Disable swipe gestures on cover for now
104-
binding.playbackCover.onSwipeListener = null
113+
binding.playbackPager?.apply {
114+
adapter = coverPagerAdapter
115+
userAwarePagerCallback = UserAwarePagerCallback(this) {
116+
// Posting the queue goto command prevents the seekbar pos from desyncing
117+
// from the song's duration, which creates a visual flicker in the seekbar.
118+
post { queueModel.goto(it) }
119+
}.also { it.attach() }
120+
setPageTransformer(CarouselTransformer())
121+
recycler().apply {
122+
// Make it possible to collapse the bottom sheet from the ViewPager's touch area.
123+
isNestedScrollingEnabled = false
124+
// Visual effect consistency
125+
// TODO: Custom overscroll?
126+
overScrollMode = View.OVER_SCROLL_NEVER
127+
}
128+
// Make it easier to collapse the bottom sheet
129+
dampen()
130+
offscreenPageLimit = 1
131+
}
105132

106133
// Set up fast seek overlay
107-
binding.playbackFastSeekOverlay?.performListener = this
108134
binding.playbackSong.apply {
109135
isSelected = true
110136
setOnClickListener { navigateToCurrentSong() }
@@ -149,23 +175,25 @@ class PlaybackPanelFragment :
149175
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
150176
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
151177
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
178+
collectImmediately(playbackModel.pagerQueue, ::updatePager)
152179
}
153180

154-
override fun onStart() {
155-
super.onStart()
156-
playbackModel.song.value?.let { requireBinding().playbackCover.bind(it) }
157-
requireBinding().root.viewTreeObserver.addOnGlobalLayoutListener(this)
158-
}
181+
// FIXME: Old code!! Maybe not necessary anymore?
182+
// override fun onStart() {
183+
// super.onStart()
184+
// playbackModel.song.value?.let { requireBinding().playbackCover.bind(it) }
185+
// requireBinding().root.viewTreeObserver.addOnGlobalLayoutListener(this)
186+
// }
159187

160-
override fun onStop() {
161-
super.onStop()
162-
requireBinding().root.viewTreeObserver.removeOnGlobalLayoutListener(this)
163-
}
188+
// override fun onStop() {
189+
// super.onStop()
190+
// requireBinding().root.viewTreeObserver.removeOnGlobalLayoutListener(this)
191+
// }
164192

165-
override fun onGlobalLayout() {
166-
if (binding == null || lastCoverWidth < 0) {
167-
return
168-
}
193+
// override fun onGlobalLayout() {
194+
// if (binding == null || lastCoverWidth < 0) {
195+
// return
196+
// }
169197
// Hacky workaround for cover radius not being preserved in between sizing changes
170198
// (i.e split screen or landscape mode)
171199
// For some reason ConstraintLayout does several passes on 1:1 elements that causes their
@@ -175,15 +203,15 @@ class PlaybackPanelFragment :
175203
// covers) we can force it to reload.
176204
// If this breaks, it's fine since we also started a load as we normally did w/state
177205
// updates, so the cover will not break.
178-
val binding = requireBinding()
179-
val coverWidth = binding.playbackCover.width
180-
if (lastCoverWidth != coverWidth) {
181-
lastCoverWidth = coverWidth
182-
} else {
183-
playbackModel.song.value?.let { binding.playbackCover.bind(it) }
184-
lastCoverWidth = -1
185-
}
186-
}
206+
// val binding = requireBinding()
207+
// val coverWidth = binding.playbackCover.width
208+
// if (lastCoverWidth != coverWidth) {
209+
// lastCoverWidth = coverWidth
210+
// } else {
211+
// playbackModel.song.value?.let { binding.playbackCover.bind(it) }
212+
// lastCoverWidth = -1
213+
// }
214+
// }
187215

188216
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
189217
equalizerLauncher = null
@@ -192,6 +220,8 @@ class PlaybackPanelFragment :
192220
binding.playbackArtist.isSelected = false
193221
binding.playbackAlbum?.isSelected = false
194222
binding.playbackToolbar.setOnMenuItemClickListener(null)
223+
userAwarePagerCallback?.release()
224+
binding.playbackPager?.adapter = null
195225
}
196226

197227
override fun onMenuItemClick(item: MenuItem): Boolean {
@@ -231,7 +261,6 @@ class PlaybackPanelFragment :
231261
val binding = requireBinding()
232262
val context = requireContext()
233263
L.d("Updating song display: $song")
234-
binding.playbackCover.bind(song)
235264
binding.playbackSong.text = song.name.resolve(context)
236265
binding.playbackArtist.text = song.artists.resolveNames(context)
237266
binding.playbackAlbum?.text = song.album.name.resolve(context)
@@ -264,6 +293,40 @@ class PlaybackPanelFragment :
264293
requireBinding().playbackShuffle.isChecked = isShuffled
265294
}
266295

296+
private fun updatePager(queue: PagerQueue) {
297+
val binding = requireBinding()
298+
299+
val command = playbackModel.pagerCommand.consume()
300+
if (command == null) {
301+
// This probably shouldn't happen in practice, as QueueViewModel directly
302+
// attaches to PlaybackStateManager and will basically always initialize
303+
// with a command as a result.
304+
//
305+
// If it does happen we should just make sure the UI state is aligned. Don't
306+
// want broken UI.
307+
coverPagerAdapter.update(queue.queue, null)
308+
binding.playbackPager.setCurrentItem(queue.index, false)
309+
return
310+
}
311+
312+
if (command.update != null) {
313+
// queue needs to be updated.
314+
coverPagerAdapter.update(queue.queue, command.update)
315+
}
316+
317+
if (command.scroll != null) {
318+
// we need to scroll, however the smooth scroll only really looks best
319+
// when we are only doing next/prev due to various factors. better to
320+
// just not animate on outright gotos or queue updates
321+
val delta = binding.playbackPager.currentItem - command.scroll
322+
if (delta == 0) {
323+
// user scroll, carry on
324+
return
325+
}
326+
binding.playbackPager.setCurrentItem(command.scroll, command.update == null && abs(delta) == 1)
327+
}
328+
}
329+
267330
private fun navigateToCurrentSong() {
268331
playbackModel.song.value?.let(detailModel::showAlbum)
269332
}
@@ -282,4 +345,8 @@ class PlaybackPanelFragment :
282345
Direction.BACKWARDS -> playbackModel.stepBackwards()
283346
}
284347
}
348+
349+
private companion object {
350+
351+
}
285352
}

app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
2828
import kotlinx.coroutines.flow.StateFlow
2929
import kotlinx.coroutines.launch
3030
import org.oxycblt.auxio.list.ListSettings
31+
import org.oxycblt.auxio.list.adapter.UpdateInstructions
3132
import org.oxycblt.auxio.playback.state.DeferredPlayback
3233
import org.oxycblt.auxio.playback.state.PlaybackCommand
3334
import org.oxycblt.auxio.playback.state.PlaybackStateManager
@@ -104,6 +105,15 @@ constructor(
104105
val openPanel: Event<OpenPanel>
105106
get() = _openPanel
106107

108+
private val _pagerQueue = MutableStateFlow(PagerQueue(listOf(), 0))
109+
/** The current queue in a special bundled format suitable for the cover ViewPager2. */
110+
val pagerQueue: StateFlow<PagerQueue> = _pagerQueue
111+
112+
private val _pagerCommand = MutableEvent<PagerCommand>()
113+
/** Specialized ViewPager2-friendly queue commands */
114+
val pagerCommand: Event<PagerCommand>
115+
get() = _pagerCommand
116+
107117
private val _playbackDecision = MutableEvent<PlaybackDecision>()
108118
/**
109119
* A [PlaybackDecision] command that is awaiting a view capable of responding to it. Null if
@@ -130,7 +140,16 @@ constructor(
130140

131141
override fun onIndexMoved(index: Int) {
132142
L.d("Index moved, updating current song")
143+
_positionDs.value = playbackManager.progression.calculateElapsedPositionMs().msToDs()
133144
_song.value = playbackManager.currentSong
145+
146+
_pagerCommand.put(
147+
PagerCommand(
148+
update = null,
149+
scroll = index
150+
)
151+
)
152+
_pagerQueue.value = _pagerQueue.value.copy(index = index)
134153
}
135154

136155
override fun onQueueChanged(queue: List<Song>, index: Int, change: QueueChange) {
@@ -139,11 +158,33 @@ constructor(
139158
L.d("Queue changed, updating current song")
140159
_song.value = playbackManager.currentSong
141160
}
161+
162+
_pagerCommand.put(
163+
PagerCommand(
164+
update = change.instructions,
165+
scroll = index.takeIf { change.type != QueueChange.Type.MAPPING }
166+
)
167+
)
168+
_pagerQueue.value = PagerQueue(
169+
queue = queue,
170+
index = index
171+
)
142172
}
143173

144174
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
145175
L.d("Queue completely changed, updating current song")
146176
_isShuffled.value = isShuffled
177+
178+
_pagerCommand.put(
179+
PagerCommand(
180+
update = UpdateInstructions.Replace(0),
181+
scroll = index
182+
)
183+
)
184+
_pagerQueue.value = PagerQueue(
185+
queue = queue,
186+
index = index
187+
)
147188
}
148189

149190
override fun onNewPlayback(
@@ -156,6 +197,17 @@ constructor(
156197
_song.value = playbackManager.currentSong
157198
_parent.value = parent
158199
_isShuffled.value = isShuffled
200+
201+
_pagerCommand.put(
202+
PagerCommand(
203+
update = UpdateInstructions.Replace(0),
204+
scroll = index
205+
)
206+
)
207+
_pagerQueue.value = PagerQueue(
208+
queue = queue,
209+
index = index
210+
)
159211
}
160212

161213
override fun onProgressionChanged(progression: Progression) {
@@ -632,6 +684,17 @@ constructor(
632684
}
633685
}
634686

687+
688+
data class PagerQueue(
689+
val queue: List<Song>,
690+
val index: Int
691+
)
692+
693+
data class PagerCommand(
694+
val update: UpdateInstructions?,
695+
val scroll: Int?
696+
)
697+
635698
/**
636699
* Command for controlling the main playback panel UI.
637700
*

0 commit comments

Comments
 (0)