Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import org.oxycblt.auxio.playback.ui.swiper.CarouselTransformer
import org.oxycblt.auxio.playback.ui.swiper.CoverPagerAdapter
import org.oxycblt.auxio.playback.ui.swiper.UserAwarePagerCallback
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.dampen
import org.oxycblt.auxio.util.recycler
Expand Down Expand Up @@ -166,6 +167,7 @@ class PlaybackPanelFragment :
}
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
binding.playbackShuffle.setOnClickListener { playbackModel.toggleShuffled() }
binding.playbackLike.setOnClickListener { playbackModel.likeCurrentSong() }
binding.playbackMore?.setOnClickListener {
playbackModel.song.value?.let {
listModel.openMenu(R.menu.playback_song, it, PlaySong.ByItself)
Expand All @@ -179,7 +181,10 @@ class PlaybackPanelFragment :
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
collectImmediately(playbackModel.isLiked, ::updateLiked)
collectImmediately(playbackModel.pagerQueue, ::updatePager)
collect(playbackModel.likedEvent.flow, ::handleLikedEvent)
collect(playbackModel.dislikedEvent.flow, ::handleDislikedEvent)
}

// FIXME: Old code!! Maybe not necessary anymore?
Expand Down Expand Up @@ -297,6 +302,25 @@ class PlaybackPanelFragment :
requireBinding().playbackShuffle.isChecked = isShuffled
}

private fun updateLiked(isLiked: Boolean) {
val likeButton = requireBinding().playbackLike
likeButton.isChecked = isLiked
likeButton.contentDescription =
getString(if (isLiked) R.string.desc_liked else R.string.desc_like)
}

private fun handleLikedEvent(event: Unit?) {
if (event == null) return
playbackModel.likedEvent.consume()
requireContext().showToast(R.string.lng_song_liked)
}

private fun handleDislikedEvent(event: Unit?) {
if (event == null) return
playbackModel.dislikedEvent.consume()
requireContext().showToast(R.string.lng_song_disliked)
}

private fun updatePager(queue: PagerQueue) {
// Right now there's easily 140ms of frame skipping when going next/prev. This is primarily
// the fault of specifically the nested bottom sheet UI setup, which is intractable to
Expand Down
59 changes: 59 additions & 0 deletions app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.state.ShuffleMode
import org.oxycblt.auxio.smartshuffle.SmartShuffle
import org.oxycblt.auxio.smartshuffle.SmartShuffleTracker
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
Expand All @@ -61,6 +63,8 @@ constructor(
private val playbackSettings: PlaybackSettings,
private val commandFactory: PlaybackCommand.Factory,
private val listSettings: ListSettings,
private val smartShuffle: SmartShuffle,
private val smartShuffleTracker: SmartShuffleTracker,
) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener {
private var lastPositionJob: Job? = null

Expand All @@ -77,6 +81,21 @@ constructor(
val isPlaying: StateFlow<Boolean>
get() = _isPlaying

private val _isLiked = MutableStateFlow(false)
/** Whether the current song was explicitly liked for Smart Shuffle. */
val isLiked: StateFlow<Boolean>
get() = _isLiked

private val _likedEvent = MutableEvent<Unit>()
/** One-shot event after the user likes the current song. */
val likedEvent: Event<Unit>
get() = _likedEvent

private val _dislikedEvent = MutableEvent<Unit>()
/** One-shot event after the user removes a like (explicit dislike). */
val dislikedEvent: Event<Unit>
get() = _dislikedEvent

private val _positionDs = MutableStateFlow(0L)
/** The current position, in deci-seconds (1/10th of a second). */
val positionDs: StateFlow<Long>
Expand Down Expand Up @@ -131,6 +150,10 @@ constructor(
init {
playbackManager.addListener(this)
playbackSettings.registerListener(this)
refreshPreferenceState()
viewModelScope.launch {
smartShuffle.preferenceRevision.collect { refreshPreferenceState() }
}
}

override fun onCleared() {
Expand All @@ -142,6 +165,7 @@ constructor(
L.d("Index moved, updating current song")
_positionDs.value = playbackManager.progression.calculateElapsedPositionMs().msToDs()
_song.value = playbackManager.currentSong
refreshPreferenceState()

_pagerCommand.put(PagerCommand(update = null, scroll = index))
_pagerQueue.value = _pagerQueue.value.copy(index = index)
Expand All @@ -152,6 +176,7 @@ constructor(
if (change.type == QueueChange.Type.SONG) {
L.d("Queue changed, updating current song")
_song.value = playbackManager.currentSong
refreshPreferenceState()
}

_pagerCommand.put(
Expand Down Expand Up @@ -181,6 +206,7 @@ constructor(
_song.value = playbackManager.currentSong
_parent.value = parent
_isShuffled.value = isShuffled
refreshPreferenceState()

_pagerCommand.put(PagerCommand(update = UpdateInstructions.Replace(0), scroll = index))
_pagerQueue.value = PagerQueue(queue = queue, index = index)
Expand Down Expand Up @@ -623,6 +649,39 @@ constructor(
playbackManager.shuffled(!playbackManager.isShuffled)
}

/**
* Toggle preference for the current song.
*
* - Not liked → like (boost recommendations, clear undesirable)
* - Already liked → dislike (mark undesirable, skip to next)
*/
fun likeCurrentSong() {
val song = playbackManager.currentSong
if (song == null) {
L.w("Cannot like/dislike without a current song")
return
}
if (smartShuffle.isLiked(song)) {
L.d("Already liked — disliking current song")
smartShuffle.dislike(song)
_isLiked.value = false
_dislikedEvent.put(Unit)
// Dislike already recorded preference; don't also count this as an early skip.
smartShuffleTracker.suppressFinish(song)
playbackManager.next()
} else {
L.d("Liking current song")
smartShuffle.like(song)
_isLiked.value = true
_likedEvent.put(Unit)
}
}

private fun refreshPreferenceState() {
val song = playbackManager.currentSong
_isLiked.value = song != null && smartShuffle.isLiked(song)
}

/**
* Toggle [repeatMode] (ex. from [RepeatMode.NONE] to [RepeatMode.TRACK])
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder {
}

companion object {
fun identity(length: Int) = BetterShuffleOrder(IntArray(length) { it })

private fun createShuffledList(length: Int, startIndex: Int): IntArray {
val shuffled = IntArray(length)
for (i in 0 until length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,13 @@ class ExoPlaybackStateHolder(
?.let { command.queue.indexOf(it) }
.also { check(it != -1) { "Start song not in queue" } }
if (command.shuffled) {
player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1))
player.setShuffleOrder(
if (command.orderedShuffle) {
BetterShuffleOrder.identity(command.queue.size)
} else {
BetterShuffleOrder(command.queue.size, startIndex ?: -1)
}
)
}
val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled)
player.seekTo(target, C.TIME_UNSET)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.smartshuffle.SmartShuffle
import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent
import org.oxycblt.musikr.MusicParent
Expand All @@ -66,6 +67,7 @@ private constructor(
private val bitmapProvider: BitmapProvider,
private val imageSettings: ImageSettings,
private val mediaSessionInterface: MediaSessionInterface,
private val smartShuffle: SmartShuffle,
) : PlaybackStateManager.Listener, ImageSettings.Listener {

class Factory
Expand All @@ -75,6 +77,7 @@ private constructor(
private val bitmapProvider: BitmapProvider,
private val imageSettings: ImageSettings,
private val mediaSessionInterface: MediaSessionInterface,
private val smartShuffle: SmartShuffle,
) {
fun create(context: Context, foregroundListener: ForegroundListener) =
MediaSessionHolder(
Expand All @@ -84,6 +87,7 @@ private constructor(
bitmapProvider,
imageSettings,
mediaSessionInterface,
smartShuffle,
)
}

Expand Down Expand Up @@ -346,9 +350,32 @@ private constructor(
.build()
state.addCustomAction(shuffleAction)

// Like toggle for Smart Shuffle (Android Auto + notification).
// Filled heart = liked; tapping again dislikes and skips.
val song = playbackManager.currentSong
val liked = song != null && smartShuffle.isLiked(song)
val likeAction =
PlaybackStateCompat.CustomAction.Builder(
PlaybackActions.ACTION_LIKE,
context.getString(
if (liked) R.string.desc_liked else R.string.lbl_like
),
if (liked) R.drawable.ic_heart_filled_24 else R.drawable.ic_heart_outline_24,
)
.build()
state.addCustomAction(likeAction)

mediaSession.setPlaybackState(state.build())
}

/** Refresh session/notification after an explicit like from Android Auto or UI. */
fun refreshAfterLike() {
invalidateSessionState()
if (!bitmapProvider.isBusy) {
foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
}
}

/** Invalidate both repeat and shuffle notification actions. */
private fun invalidateNotificationActions() {
L.d("Invalidating notification actions")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.oxycblt.auxio.BuildConfig
object PlaybackActions {
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
const val ACTION_LIKE = BuildConfig.APPLICATION_ID + ".action.LIKE"
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.smartshuffle.SmartShuffle
import org.oxycblt.auxio.smartshuffle.SmartShuffleTracker
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
Expand All @@ -50,6 +52,8 @@ private constructor(
sessionHolderFactory: MediaSessionHolder.Factory,
widgetComponentFactory: WidgetComponent.Factory,
systemReceiverFactory: SystemPlaybackReceiver.Factory,
private val smartShuffleTracker: SmartShuffleTracker,
smartShuffle: SmartShuffle,
) : PlaybackStateManager.Listener {
class Factory
@Inject
Expand All @@ -60,6 +64,8 @@ private constructor(
private val sessionHolderFactory: MediaSessionHolder.Factory,
private val widgetComponentFactory: WidgetComponent.Factory,
private val systemReceiverFactory: SystemPlaybackReceiver.Factory,
private val smartShuffleTracker: SmartShuffleTracker,
private val smartShuffle: SmartShuffle,
) {
fun create(context: Context, foregroundListener: ForegroundListener) =
PlaybackServiceFragment(
Expand All @@ -71,6 +77,8 @@ private constructor(
sessionHolderFactory,
widgetComponentFactory,
systemReceiverFactory,
smartShuffleTracker,
smartShuffle,
)
}

Expand All @@ -85,8 +93,16 @@ private constructor(
context,
widgetComponent,
onExitRequested = { playbackManager.endSession() },
onLikeChanged = { sessionHolder.refreshAfterLike() },
)

init {
// Keep Android Auto / notification like action in sync with mobile likes.
scope.launch {
smartShuffle.preferenceRevision.collect { sessionHolder.refreshAfterLike() }
}
}

private fun scheduleAutoStop() {
autoStopJob?.cancel()
autoStopJob =
Expand Down Expand Up @@ -119,6 +135,7 @@ private constructor(
sessionHolder.attach()
widgetComponent.attach()
systemReceiver.attach()
smartShuffleTracker.start()
playbackManager.addListener(this)
updateAutoStopTimer(playbackManager.progression.isPlaying)
return sessionHolder.token
Expand Down Expand Up @@ -176,6 +193,7 @@ private constructor(
autoStopJob?.cancel()
waitJob.cancel()
playbackManager.removeListener(this)
smartShuffleTracker.stop()
systemReceiver.release()
widgetComponent.release()
sessionHolder.release()
Expand Down
Loading