From 39779e820f1b78c79b80cb917b8e97139bf9a47b Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Sat, 8 Nov 2025 23:12:56 +0530 Subject: [PATCH 1/4] fix: prevent seek flicker by removing lazy seeking --- .../musicplayer/activities/TrackActivity.kt | 19 +++-- .../musicplayer/helpers/MyWidgetProvider.kt | 4 +- .../playback/player/SimpleMusicPlayer.kt | 75 +------------------ 3 files changed, 19 insertions(+), 79 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt b/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt index 2371a632..e1113861 100644 --- a/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt +++ b/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt @@ -31,12 +31,12 @@ import org.fossify.commons.extensions.getProperBackgroundColor import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.getProperTextColor import org.fossify.commons.extensions.realScreenSize +import org.fossify.commons.extensions.setDebouncedClickListener import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.extensions.value import org.fossify.commons.extensions.viewBinding import org.fossify.commons.helpers.MEDIUM_ALPHA -import org.fossify.commons.helpers.mydebug import org.fossify.musicplayer.R import org.fossify.musicplayer.databinding.ActivityTrackBinding import org.fossify.musicplayer.extensions.config @@ -63,13 +63,16 @@ import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { - private val SWIPE_DOWN_THRESHOLD = 100 + companion object { + private const val SWIPE_DOWN_THRESHOLD = 100 + private const val DEBOUNCE_INTERVAL_MS = 150L + private const val UPDATE_INTERVAL_MS = 500L + } private var isThirdPartyIntent = false private lateinit var nextTrackPlaceholder: Drawable private val handler = Handler(Looper.getMainLooper()) - private val updateIntervalMillis = 500L private val binding by viewBinding(ActivityTrackBinding::inflate) @@ -177,9 +180,13 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { private fun setupButtons() = binding.apply { activityTrackToggleShuffle.setOnClickListener { withPlayer { toggleShuffle() } } - activityTrackPrevious.setOnClickListener { withPlayer { seekToPrevious() } } + activityTrackPrevious.setDebouncedClickListener(DEBOUNCE_INTERVAL_MS) { + withPlayer { seekToPrevious() } + } activityTrackPlayPause.setOnClickListener { togglePlayback() } - activityTrackNext.setOnClickListener { withPlayer { seekToNext() } } + activityTrackNext.setDebouncedClickListener(DEBOUNCE_INTERVAL_MS) { + withPlayer { seekToNext() } + } activityTrackProgressCurrent.setOnClickListener { seekBack() } activityTrackProgressMax.setOnClickListener { seekForward() } activityTrackPlaybackSetting.setOnClickListener { togglePlaybackSetting() } @@ -437,7 +444,7 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { private fun scheduleProgressUpdate() { cancelProgressUpdate() withPlayer { - val delayInMillis = (updateIntervalMillis / config.playbackSpeed).toLong() + val delayInMillis = (UPDATE_INTERVAL_MS / config.playbackSpeed).toLong() handler.postDelayed(delayInMillis = delayInMillis) { updateProgress(currentPosition) scheduleProgressUpdate() diff --git a/app/src/main/kotlin/org/fossify/musicplayer/helpers/MyWidgetProvider.kt b/app/src/main/kotlin/org/fossify/musicplayer/helpers/MyWidgetProvider.kt index 0f567def..fd4cfcbf 100644 --- a/app/src/main/kotlin/org/fossify/musicplayer/helpers/MyWidgetProvider.kt +++ b/app/src/main/kotlin/org/fossify/musicplayer/helpers/MyWidgetProvider.kt @@ -59,8 +59,8 @@ class MyWidgetProvider : AppWidgetProvider() { context.startActivity(intent) } else { when (action) { - NEXT -> player.seekToNextMediaItem() - PREVIOUS -> if (player.contentPosition > 5000) player.seekTo(0) else player.seekToPreviousMediaItem() + NEXT -> player.seekToNext() + PREVIOUS -> player.seekToPrevious() PLAYPAUSE -> player.togglePlayback() } } diff --git a/app/src/main/kotlin/org/fossify/musicplayer/playback/player/SimpleMusicPlayer.kt b/app/src/main/kotlin/org/fossify/musicplayer/playback/player/SimpleMusicPlayer.kt index 70f32bc7..1dd74a63 100644 --- a/app/src/main/kotlin/org/fossify/musicplayer/playback/player/SimpleMusicPlayer.kt +++ b/app/src/main/kotlin/org/fossify/musicplayer/playback/player/SimpleMusicPlayer.kt @@ -6,16 +6,10 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.fossify.musicplayer.extensions.currentMediaItems import org.fossify.musicplayer.extensions.maybeForceNext import org.fossify.musicplayer.extensions.maybeForcePrevious import org.fossify.musicplayer.extensions.move -import org.fossify.musicplayer.extensions.runOnPlayerThread import org.fossify.musicplayer.extensions.shuffledMediaItemsIndices import org.fossify.musicplayer.inlines.indexOfFirstOrNull @@ -24,12 +18,6 @@ private const val DEFAULT_SHUFFLE_ORDER_SEED = 42L @UnstableApi class SimpleMusicPlayer(private val exoPlayer: ExoPlayer) : ForwardingPlayer(exoPlayer) { - private var seekToNextCount = 0 - private var seekToPreviousCount = 0 - - private val scope = CoroutineScope(Dispatchers.Default) - private var seekJob: Job? = null - /** * The default implementation only advertises the seek to next and previous item in the case * that it's not the first or last track. We manually advertise that these @@ -69,8 +57,7 @@ class SimpleMusicPlayer(private val exoPlayer: ExoPlayer) : ForwardingPlayer(exo override fun seekToNext() { play() if (!maybeForceNext()) { - seekToNextCount += 1 - seekWithDelay() + super.seekToNext() } } @@ -79,26 +66,13 @@ class SimpleMusicPlayer(private val exoPlayer: ExoPlayer) : ForwardingPlayer(exo if (currentPosition > 5000) { seekTo(0) } else if (!maybeForcePrevious()) { - seekToPreviousCount += 1 - seekWithDelay() + super.seekToPrevious() } } - override fun seekToNextMediaItem() { - play() - if (!maybeForceNext()) { - seekToNextCount += 1 - seekWithDelay() - } - } + override fun seekToNextMediaItem() = seekToNext() - override fun seekToPreviousMediaItem() { - play() - if (!maybeForcePrevious()) { - seekToPreviousCount += 1 - seekWithDelay() - } - } + override fun seekToPreviousMediaItem() = seekToPrevious() fun getAudioSessionId(): Int { return exoPlayer.audioSessionId @@ -147,45 +121,4 @@ class SimpleMusicPlayer(private val exoPlayer: ExoPlayer) : ForwardingPlayer(exo ) } } - - /** - * This is here so the player can quickly seek next/previous without doing too much work. - * It probably won't be needed once https://github.com/androidx/media/issues/81 is resolved. - */ - private fun seekWithDelay() { - seekJob?.cancel() - seekJob = scope.launch { - delay(timeMillis = 300) - val seekCount = seekToNextCount - seekToPreviousCount - if (seekCount != 0) { - seekByCount(seekCount) - } - } - } - - private fun seekByCount(seekCount: Int) { - runOnPlayerThread { - if (currentMediaItem == null) { - return@runOnPlayerThread - } - - val currentIndex = currentMediaItemIndex - val seekIndex = if (shuffleModeEnabled) { - val shuffledIndex = shuffledMediaItemsIndices.indexOf(currentIndex) - val seekIndex = rotateIndex(shuffledIndex + seekCount) - shuffledMediaItemsIndices.getOrNull(seekIndex) ?: return@runOnPlayerThread - } else { - rotateIndex(currentIndex + seekCount) - } - - seekTo(seekIndex, 0) - seekToNextCount = 0 - seekToPreviousCount = 0 - } - } - - private fun rotateIndex(index: Int): Int { - val count = mediaItemCount - return (index % count + count) % count - } } From c5d4f750cb0ee1b45e7c8384f52a24b4ba021ed3 Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Sat, 8 Nov 2025 23:13:50 +0530 Subject: [PATCH 2/4] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0913606..38922a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed - Fixed another crash when clearing app from recents ([#298]) +- Fixed flicker when seeking on the player screen ## [1.5.1] - 2025-11-05 ### Fixed From 864e89306538c8cbd14fe958005d2063631203bf Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:50:25 +0530 Subject: [PATCH 3/4] fix: reduce debounce interval --- .../kotlin/org/fossify/musicplayer/activities/TrackActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt b/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt index e1113861..5d7b2e82 100644 --- a/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt +++ b/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt @@ -66,7 +66,7 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { companion object { private const val SWIPE_DOWN_THRESHOLD = 100 private const val DEBOUNCE_INTERVAL_MS = 150L - private const val UPDATE_INTERVAL_MS = 500L + private const val UPDATE_INTERVAL_MS = 150L } private var isThirdPartyIntent = false From 098ee851211062ff608ce8e3d21807c61d1a70c8 Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Sun, 9 Nov 2025 11:58:21 +0530 Subject: [PATCH 4/4] fix: prevent ANRs when seeking quickly --- .../musicplayer/activities/TrackActivity.kt | 136 +++++++++++++----- 1 file changed, 103 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt b/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt index 5d7b2e82..ee431829 100644 --- a/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt +++ b/app/src/main/kotlin/org/fossify/musicplayer/activities/TrackActivity.kt @@ -20,6 +20,11 @@ import androidx.media3.common.MediaItem import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.RequestOptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.fossify.commons.extensions.applyColorFilter import org.fossify.commons.extensions.beGone import org.fossify.commons.extensions.beInvisibleIf @@ -31,7 +36,6 @@ import org.fossify.commons.extensions.getProperBackgroundColor import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.getProperTextColor import org.fossify.commons.extensions.realScreenSize -import org.fossify.commons.extensions.setDebouncedClickListener import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.extensions.value @@ -49,6 +53,7 @@ import org.fossify.musicplayer.extensions.loadGlideResource import org.fossify.musicplayer.extensions.nextMediaItem import org.fossify.musicplayer.extensions.sendCommand import org.fossify.musicplayer.extensions.setRepeatMode +import org.fossify.musicplayer.extensions.shuffledMediaItemsIndices import org.fossify.musicplayer.extensions.toTrack import org.fossify.musicplayer.extensions.updatePlayPauseIcon import org.fossify.musicplayer.fragments.PlaybackSpeedFragment @@ -65,7 +70,7 @@ import kotlin.time.Duration.Companion.milliseconds class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { companion object { private const val SWIPE_DOWN_THRESHOLD = 100 - private const val DEBOUNCE_INTERVAL_MS = 150L + private const val SEEK_COALESCE_INTERVAL_MS = 200L private const val UPDATE_INTERVAL_MS = 150L } @@ -74,13 +79,18 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { private val handler = Handler(Looper.getMainLooper()) + private val scope = CoroutineScope(Dispatchers.Default) + private var seekJob: Job? = null + private var seekCount = 0 + private val binding by viewBinding(ActivityTrackBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setupEdgeToEdge(padBottomSystem = listOf(binding.nextTrackHolder)) - nextTrackPlaceholder = resources.getColoredDrawableWithColor(R.drawable.ic_headset, getProperTextColor()) + nextTrackPlaceholder = + resources.getColoredDrawableWithColor(R.drawable.ic_headset, getProperTextColor()) setupButtons() setupFlingListener() @@ -90,7 +100,12 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { } isThirdPartyIntent = intent.action == Intent.ACTION_VIEW - arrayOf(activityTrackToggleShuffle, activityTrackPrevious, activityTrackNext, activityTrackPlaybackSetting).forEach { + arrayOf( + activityTrackToggleShuffle, + activityTrackPrevious, + activityTrackNext, + activityTrackPlaybackSetting + ).forEach { it.beInvisibleIf(isThirdPartyIntent) } @@ -101,7 +116,10 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { setupTrackInfo(PlaybackService.currentMediaItem) setupNextTrackInfo(PlaybackService.nextMediaItem) - activityTrackPlayPause.updatePlayPauseIcon(PlaybackService.isPlaying, getProperTextColor()) + activityTrackPlayPause.updatePlayPauseIcon( + isPlaying = PlaybackService.isPlaying, + color = getProperTextColor() + ) updatePlayerState() nextTrackHolder.background = getProperBackgroundColor().toDrawable() @@ -180,13 +198,9 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { private fun setupButtons() = binding.apply { activityTrackToggleShuffle.setOnClickListener { withPlayer { toggleShuffle() } } - activityTrackPrevious.setDebouncedClickListener(DEBOUNCE_INTERVAL_MS) { - withPlayer { seekToPrevious() } - } + activityTrackPrevious.setOnClickListener { seekWithDelay(previous = true) } activityTrackPlayPause.setOnClickListener { togglePlayback() } - activityTrackNext.setDebouncedClickListener(DEBOUNCE_INTERVAL_MS) { - withPlayer { seekToNext() } - } + activityTrackNext.setOnClickListener { seekWithDelay() } activityTrackProgressCurrent.setOnClickListener { seekBack() } activityTrackProgressMax.setOnClickListener { seekForward() } activityTrackPlaybackSetting.setOnClickListener { togglePlaybackSetting() } @@ -208,17 +222,20 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { } binding.nextTrackHolder.beVisible() - val artist = if (track.artist.trim().isNotEmpty() && track.artist != MediaStore.UNKNOWN_STRING) { - " • ${track.artist}" - } else { - "" - } + val artist = + if (track.artist.trim().isNotEmpty() && track.artist != MediaStore.UNKNOWN_STRING) { + " • ${track.artist}" + } else { + "" + } @SuppressLint("SetTextI18n") binding.nextTrackLabel.text = "${getString(R.string.next_track)} ${track.title}$artist" getTrackCoverArt(track) { coverArt -> - val cornerRadius = resources.getDimension(org.fossify.commons.R.dimen.rounded_corner_radius_small).toInt() + val cornerRadius = + resources.getDimension(org.fossify.commons.R.dimen.rounded_corner_radius_small) + .toInt() val wantedSize = resources.getDimension(R.dimen.song_image_size).toInt() // change cover image manually only once loaded successfully to avoid blinking at fails and placeholders @@ -278,7 +295,12 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { @SuppressLint("ClickableViewAccessibility") private fun setupFlingListener() { val flingListener = object : GestureDetector.SimpleOnGestureListener() { - override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { if (e1 != null) { if (velocityY > 0 && velocityY > velocityX && e2.y - e1.y > SWIPE_DOWN_THRESHOLD) { finish() @@ -312,7 +334,8 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { binding.activityTrackToggleShuffle.apply { applyColorFilter(if (isShuffleEnabled) getProperPrimaryColor() else getProperTextColor()) alpha = if (isShuffleEnabled) 1f else MEDIUM_ALPHA - contentDescription = getString(if (isShuffleEnabled) R.string.disable_shuffle else R.string.enable_shuffle) + contentDescription = + getString(if (isShuffleEnabled) R.string.disable_shuffle else R.string.enable_shuffle) } } @@ -358,18 +381,19 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { binding.activityTrackSpeedIcon.applyColorFilter(getProperTextColor()) updatePlaybackSpeed(config.playbackSpeed) - binding.activityTrackProgressbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - val formattedProgress = progress.getFormattedDuration() - binding.activityTrackProgressCurrent.text = formattedProgress - } + binding.activityTrackProgressbar.setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val formattedProgress = progress.getFormattedDuration() + binding.activityTrackProgressCurrent.text = formattedProgress + } - override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStartTrackingTouch(seekBar: SeekBar) {} - override fun onStopTrackingTouch(seekBar: SeekBar) = withPlayer { - seekTo(seekBar.progress * 1000L) - } - }) + override fun onStopTrackingTouch(seekBar: SeekBar) = withPlayer { + seekTo(seekBar.progress * 1000L) + } + }) } private fun showPlaybackSpeedPicker() { @@ -383,7 +407,8 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { if (isSlow != binding.activityTrackSpeed.tag as? Boolean) { binding.activityTrackSpeed.tag = isSlow - val drawableId = if (isSlow) R.drawable.ic_playback_speed_slow_vector else R.drawable.ic_playback_speed_vector + val drawableId = + if (isSlow) R.drawable.ic_playback_speed_slow_vector else R.drawable.ic_playback_speed_vector binding.activityTrackSpeedIcon.setImageDrawable(resources.getDrawable(drawableId)) } @@ -404,9 +429,13 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { override fun onIsPlayingChanged(isPlaying: Boolean) = updatePlayerState() - override fun onRepeatModeChanged(repeatMode: Int) = maybeUpdatePlaybackSettingButton(getPlaybackSetting(repeatMode)) + override fun onRepeatModeChanged(repeatMode: Int) { + maybeUpdatePlaybackSettingButton(getPlaybackSetting(repeatMode)) + } - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) = setupShuffleButton(shuffleModeEnabled) + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + setupShuffleButton(shuffleModeEnabled) + } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { super.onMediaItemTransition(mediaItem, reason) @@ -457,10 +486,51 @@ class TrackActivity : SimpleControllerActivity(), PlaybackSpeedListener { } private fun updateProgress(currentPosition: Long) { - binding.activityTrackProgressbar.progress = currentPosition.milliseconds.inWholeSeconds.toInt() + binding.activityTrackProgressbar.progress = + currentPosition.milliseconds.inWholeSeconds.toInt() } private fun updatePlayPause(isPlaying: Boolean) { binding.activityTrackPlayPause.updatePlayPauseIcon(isPlaying, getProperTextColor()) } + + /** + * This is here so the player can quickly seek next/previous without doing too much work. + * It probably won't be needed once https://github.com/androidx/media/issues/81 is resolved. + */ + private fun seekWithDelay(previous: Boolean = false) { + if (previous) seekCount -= 1 else seekCount += 1 + seekJob?.cancel() + seekJob = scope.launch { + delay(timeMillis = SEEK_COALESCE_INTERVAL_MS) + if (seekCount != 0) { + seekByCount(seekCount) + } + } + } + + private fun seekByCount(count: Int) { + withPlayer { + if (currentMediaItem == null) { + return@withPlayer + } + + val currentIndex = currentMediaItemIndex + val mediaItemCount = mediaItemCount + val seekIndex = if (shuffleModeEnabled) { + val shuffledIndex = shuffledMediaItemsIndices.indexOf(currentIndex) + val seekIndex = rotateIndex(mediaItemCount, shuffledIndex + count) + shuffledMediaItemsIndices.getOrNull(seekIndex) ?: return@withPlayer + } else { + rotateIndex(mediaItemCount, currentIndex + count) + } + + seekTo(seekIndex, 0) + seekCount = 0 + } + } + + private fun rotateIndex(total: Int, index: Int): Int { + return (index % total + total) % total + } }