From a38679d4a03cd91b86fae176912374026bb4b85d Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 2 Sep 2026 19:45:21 +0800 Subject: [PATCH] fixed a bug --- .../oraltrainer/sdk/OralTrainerController.kt | 112 +++++++++++++++++- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt index 438a52f..bfb20f3 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt @@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.PlayerMessage import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import java.util.concurrent.CopyOnWriteArraySet import kotlin.math.max @@ -30,6 +31,8 @@ class OralTrainerController internal constructor( private var lastSentenceIndex: Int? = null private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? = null + private var boundaryMessage: PlayerMessage? = null + private var boundaryGuardToken = 0 var config: PlayerConfig = initialConfig private set @@ -67,13 +70,20 @@ class OralTrainerController internal constructor( } override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + refreshSentenceBoundaryGuard() + } else { + cancelSentenceBoundaryGuard() + } notifySnapshot() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { lastSentenceIndex = null + cancelSentenceBoundaryGuard() listeners.forEach { it.onMediaChanged(currentTrainingItem()) } notifySentenceIfChanged(force = true) + refreshSentenceBoundaryGuard() notifySnapshot() } @@ -83,6 +93,7 @@ class OralTrainerController internal constructor( reason: Int, ) { notifySentenceIfChanged(force = true) + refreshSentenceBoundaryGuard() notifySnapshot() } @@ -158,6 +169,7 @@ class OralTrainerController internal constructor( if (index == player.currentMediaItemIndex) { listeners.forEach { it.onMediaChanged(updatedItem) } notifySentenceIfChanged(force = true) + refreshSentenceBoundaryGuard() notifySnapshot() } return true @@ -165,11 +177,13 @@ class OralTrainerController internal constructor( fun play() { player.play() + refreshSentenceBoundaryGuard() } fun pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelSentenceBoundaryGuard() player.pause() } @@ -184,6 +198,7 @@ class OralTrainerController internal constructor( fun stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelSentenceBoundaryGuard() player.stop() } @@ -191,6 +206,7 @@ class OralTrainerController internal constructor( stopAtSentenceEnd = false stopAtSentenceEndIndex = null player.seekTo(max(0L, positionMs)) + refreshSentenceBoundaryGuard() } fun seekRelative(deltaMs: Long) { @@ -237,6 +253,7 @@ class OralTrainerController internal constructor( fun setContinuousPlayback(enabled: Boolean) { config = config.copy(continuousPlayback = enabled) + refreshSentenceBoundaryGuard() } fun playCurrentSentenceAndStop() { @@ -245,6 +262,7 @@ class OralTrainerController internal constructor( stopAtSentenceEnd = true stopAtSentenceEndIndex = sentence.index player.play() + refreshSentenceBoundaryGuard() } fun currentTrainingItem(): TrainingMediaItem? { @@ -357,6 +375,7 @@ class OralTrainerController internal constructor( return } released = true + cancelSentenceBoundaryGuard() mainHandler.removeCallbacks(ticker) listeners.clear() player.release() @@ -376,8 +395,12 @@ class OralTrainerController internal constructor( if (!force && index == lastSentenceIndex) { return } + val changed = index != lastSentenceIndex lastSentenceIndex = index listeners.forEach { it.onSentenceChanged(sentence) } + if (changed && !config.continuousPlayback && !stopAtSentenceEnd) { + refreshSentenceBoundaryGuard() + } } private fun enforceSentenceBoundary() { @@ -393,23 +416,100 @@ class OralTrainerController internal constructor( val index = stopAtSentenceEndIndex ?: return val sentence = item.sentences.getOrNull(index) ?: return if (position >= sentence.endMs) { - stopAtSentenceEnd = false - stopAtSentenceEndIndex = null - player.pause() - player.seekTo(sentence.startMs) + applySentenceEndAction(sentence) } return } if (config.continuousPlayback) { return } - val anchorIndex = lastSentenceIndex ?: return + val anchorIndex = lastSentenceIndex ?: currentSentence()?.index ?: return val anchor = item.sentences.getOrNull(anchorIndex) ?: return if (position >= anchor.endMs) { - player.seekTo(anchor.startMs) + applySentenceEndAction(anchor) } } + private fun refreshSentenceBoundaryGuard() { + cancelSentenceBoundaryGuard() + if (released || !config.sentenceMode) { + return + } + if (!player.isPlaying && !player.playWhenReady) { + return + } + val item = currentTrainingItem() ?: return + if (item.sentences.isEmpty()) { + return + } + + val sentence = when { + stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return) + config.continuousPlayback -> null + else -> { + val index = lastSentenceIndex ?: currentSentence()?.index + item.sentences.getOrNull(index ?: return) + } + } ?: return + + val mediaItemIndex = player.currentMediaItemIndex + if (player.currentPosition >= sentence.endMs) { + applySentenceEndAction(sentence) + return + } + + val token = ++boundaryGuardToken + boundaryMessage = player.createMessage { _, _ -> + if (token != boundaryGuardToken || released) { + return@createMessage + } + val active = currentTrainingItem() ?: return@createMessage + val latest = when { + stopAtSentenceEnd -> active.sentences.getOrNull(stopAtSentenceEndIndex ?: return@createMessage) + else -> active.sentences.getOrNull(sentence.index) + } ?: return@createMessage + if (player.currentMediaItemIndex != mediaItemIndex) { + return@createMessage + } + applySentenceEndAction(latest) + } + .setLooper(mainHandler.looper) + .setPosition(mediaItemIndex, sentence.endMs) + .setDeleteAfterDelivery(true) + .send() + } + + private fun cancelSentenceBoundaryGuard() { + boundaryGuardToken++ + boundaryMessage?.let { message -> + runCatching { message.cancel() } + } + boundaryMessage = null + } + + private fun applySentenceEndAction(sentence: SentenceBoundary) { + if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { + stopAtSentenceEnd = false + stopAtSentenceEndIndex = null + cancelSentenceBoundaryGuard() + player.pause() + player.seekTo(sentence.startMs) + lastSentenceIndex = sentence.index + notifySentenceIfChanged(force = true) + notifySnapshot() + return + } + if (config.continuousPlayback) { + return + } + // Loop the current sentence without leaking into the next one. + player.seekTo(sentence.startMs) + lastSentenceIndex = sentence.index + notifySentenceIfChanged(force = true) + refreshSentenceBoundaryGuard() + notifySnapshot() + } + private fun TrainingMediaItem.toMedia3Item(): MediaItem { val builder = MediaItem.Builder() .setMediaId(id)