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 2c1e427..cabad2e 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 @@ -10,7 +10,10 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.PlayerMessage +import androidx.media3.exoplayer.SeekParameters import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import java.util.concurrent.CopyOnWriteArraySet import kotlin.math.max @@ -34,6 +37,11 @@ class OralTrainerController internal constructor( private var clippedSentence: SentenceBoundary? = null private var updatingPlaybackWindow = false private var unclippedDurationMs = -1L + private var sentenceEndMessage: PlayerMessage? = null + private var playFromStartAfterReady = false + private var clipStartNeedsWarmup = false + private var clipStartWarmupVolume: Float? = null + private val finishClipStartWarmupRunnable = Runnable { finishClipStartWarmup() } var config: PlayerConfig = initialConfig private set @@ -48,6 +56,7 @@ class OralTrainerController internal constructor( if (released) { return } + enforceStopAtSentenceEnd() notifySentenceIfChanged() notifySnapshot() mainHandler.postDelayed(this, IDLE_POLL_MS) @@ -64,15 +73,38 @@ class OralTrainerController internal constructor( .also { exoPlayer -> exoPlayer.repeatMode = Player.REPEAT_MODE_ALL exoPlayer.playWhenReady = initialConfig.autoPlay + exoPlayer.setSeekParameters(SeekParameters.EXACT) + exoPlayer.setForegroundMode(true) + exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters + .buildUpon() + .setAudioOffloadPreferences( + AudioOffloadPreferences.Builder() + .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED) + .build() + ) + .build() exoPlayer.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_ENDED) { - onClippedSentenceEnded() + if (clipStartWarmupVolume != null) { + finishClipStartWarmup() + } else { + onClippedSentenceEnded() + } } - if (playbackState == Player.STATE_READY && unclippedDurationMs <= 0L) { - val duration = player.duration - if (duration != C.TIME_UNSET && clippedSentence == null) { - unclippedDurationMs = duration + if (playbackState == Player.STATE_READY) { + if (playFromStartAfterReady) { + playFromStartAfterReady = false + playClippedSentenceFromStart() + } + if (stopAtSentenceEnd) { + scheduleSentenceEndGuard() + } + if (unclippedDurationMs <= 0L) { + val duration = player.duration + if (duration != C.TIME_UNSET && clippedSentence == null) { + unclippedDurationMs = duration + } } } notifySnapshot() @@ -148,6 +180,8 @@ class OralTrainerController internal constructor( lastSentenceIndex = null stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelClipStartWarmup(restoreVolume = true) + cancelSentenceEndGuard() baseItemIndex = safeIndex clippedSentence = null unclippedDurationMs = -1L @@ -193,12 +227,18 @@ class OralTrainerController internal constructor( fun play() { syncPlaybackWindow() + if (clippedSentence != null && !player.isPlaying && shouldWarmClipStart()) { + playClippedSentenceFromStart() + return + } player.play() } fun pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelClipStartWarmup(restoreVolume = true) + cancelSentenceEndGuard() player.pause() applySentenceRepeatMode() } @@ -214,12 +254,16 @@ class OralTrainerController internal constructor( fun stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelClipStartWarmup(restoreVolume = true) + cancelSentenceEndGuard() player.stop() } fun seekTo(positionMs: Long) { stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelClipStartWarmup(restoreVolume = true) + cancelSentenceEndGuard() val safePositionMs = max(0L, positionMs) lockSentenceAt(safePositionMs) syncPlaybackWindow(safePositionMs) @@ -283,17 +327,25 @@ class OralTrainerController internal constructor( /** Seek to a known sentence and stop automatically when that sentence ends. */ fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) { + val item = currentTrainingItem() + val sentence = item?.sentences?.getOrNull(sentenceIndex) + cancelClipStartWarmup(restoreVolume = true) stopAtSentenceEnd = true stopAtSentenceEndIndex = sentenceIndex lastSentenceIndex = sentenceIndex - val item = currentTrainingItem() - val sentence = item?.sentences?.getOrNull(sentenceIndex) + player.playWhenReady = false if (item != null && sentence != null) { - installSentenceClip(item, sentence, 0L) + if (!isCurrentClip(sentence)) { + installSentenceClip(item, sentence, 0L) + player.playWhenReady = false + } + playClippedSentenceFromStart() } else { + cancelSentenceEndGuard() player.seekTo(max(0L, startMs)) + player.playWhenReady = true + player.play() } - player.play() notifySentenceIfChanged(force = true) notifySnapshot() } @@ -429,6 +481,8 @@ class OralTrainerController internal constructor( return } released = true + cancelClipStartWarmup(restoreVolume = false) + cancelSentenceEndGuard() mainHandler.removeCallbacks(ticker) listeners.clear() player.release() @@ -498,17 +552,28 @@ class OralTrainerController internal constructor( return } rememberUnclippedDuration() + cancelSentenceEndGuard() val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L) val startPositionMs = relativePositionMs.coerceIn(0L, lengthMs) val playWhenReady = player.playWhenReady + val warmFromStart = startPositionMs <= CLIP_START_WARMUP_POSITION_MS + clipStartNeedsWarmup = warmFromStart updatingPlaybackWindow = true clippedSentence = sentence lastSentenceIndex = sentence.index player.setMediaItem(item.toMedia3Item(sentence.startMs, sentence.endMs), startPositionMs) player.prepare() - player.playWhenReady = playWhenReady + player.playWhenReady = false applySentenceRepeatMode() updatingPlaybackWindow = false + if (playWhenReady) { + if (warmFromStart) { + playClippedSentenceFromStart() + } else { + player.playWhenReady = true + player.play() + } + } } private fun restoreUnclipped(positionMs: Long) { @@ -522,6 +587,7 @@ class OralTrainerController internal constructor( val playWhenReady = player.playWhenReady updatingPlaybackWindow = true clippedSentence = null + clipStartNeedsWarmup = false player.setMediaItems( mediaItems.map { it.toMedia3Item() }, baseItemIndex.coerceIn(0, mediaItems.lastIndex), @@ -545,16 +611,16 @@ class OralTrainerController internal constructor( private fun onClippedSentenceEnded() { val sentence = clippedSentence ?: return + if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { + handleStopAtSentenceEnd(sentence) + return + } + clipStartNeedsWarmup = false player.seekTo(0L) if (player.playbackState == Player.STATE_ENDED) { player.prepare() } - if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { - stopAtSentenceEnd = false - stopAtSentenceEndIndex = null - player.pause() - applySentenceRepeatMode() - } else if (!config.continuousPlayback) { + if (!config.continuousPlayback) { player.play() } lastSentenceIndex = sentence.index @@ -562,6 +628,63 @@ class OralTrainerController internal constructor( notifySnapshot() } + private fun enforceStopAtSentenceEnd() { + if (!stopAtSentenceEnd) { + return + } + val index = stopAtSentenceEndIndex ?: return + val sentence = currentTrainingItem()?.sentences?.getOrNull(index) ?: return + if (playbackPositionMs() >= sentence.endMs) { + handleStopAtSentenceEnd(sentence) + } + } + + private fun handleStopAtSentenceEnd(sentence: SentenceBoundary) { + if (!stopAtSentenceEnd || stopAtSentenceEndIndex != sentence.index) { + return + } + cancelSentenceEndGuard() + stopAtSentenceEnd = false + stopAtSentenceEndIndex = null + player.pause() + if (clippedSentence?.index == sentence.index) { + player.seekTo(0L) + if (player.playbackState == Player.STATE_ENDED) { + player.prepare() + } + } else { + seekInCurrentWindow(sentence.startMs) + } + applySentenceRepeatMode() + lastSentenceIndex = sentence.index + notifySentenceIfChanged(force = true) + notifySnapshot() + } + + private fun scheduleSentenceEndGuard() { + val index = stopAtSentenceEndIndex ?: return + val sentence = currentTrainingItem()?.sentences?.getOrNull(index) ?: return + cancelSentenceEndGuard() + val endInWindowMs = if (clippedSentence?.index == sentence.index) { + (sentence.endMs - sentence.startMs - 1L).coerceAtLeast(0L) + } else { + sentence.endMs + } + sentenceEndMessage = player.createMessage { _, _ -> + if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { + handleStopAtSentenceEnd(sentence) + } + }.setLooper(mainHandler.looper) + .setPosition(endInWindowMs) + .setDeleteAfterDelivery(true) + .send() + } + + private fun cancelSentenceEndGuard() { + sentenceEndMessage?.cancel() + sentenceEndMessage = null + } + private fun switchBaseItem(targetIndex: Int) { if (targetIndex !in mediaItems.indices) { return @@ -570,6 +693,8 @@ class OralTrainerController internal constructor( lastSentenceIndex = null stopAtSentenceEnd = false stopAtSentenceEndIndex = null + cancelClipStartWarmup(restoreVolume = true) + cancelSentenceEndGuard() clippedSentence = null unclippedDurationMs = -1L val item = mediaItems[targetIndex] @@ -694,9 +819,93 @@ class OralTrainerController internal constructor( return CancellableAssessment {} } + private fun isCurrentClip(sentence: SentenceBoundary): Boolean { + val current = clippedSentence ?: return false + return current.index == sentence.index && + current.startMs == sentence.startMs && + current.endMs == sentence.endMs + } + + private fun shouldWarmClipStart(): Boolean { + if (clippedSentence == null) { + return false + } + return clipStartNeedsWarmup || player.currentPosition <= CLIP_START_WARMUP_POSITION_MS + } + + private fun playClippedSentenceFromStart() { + if (released) { + return + } + if (player.playbackState == Player.STATE_IDLE || + player.playbackState == Player.STATE_ENDED || + player.playbackState == Player.STATE_BUFFERING + ) { + playFromStartAfterReady = true + player.playWhenReady = false + if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) { + player.prepare() + } + return + } + startClipStartWarmup() + } + + private fun startClipStartWarmup() { + if (released) { + return + } + mainHandler.removeCallbacks(finishClipStartWarmupRunnable) + if (clipStartWarmupVolume == null) { + clipStartWarmupVolume = player.volume + } + player.volume = 0f + player.seekTo(0L) + if (player.playbackState == Player.STATE_ENDED) { + playFromStartAfterReady = true + player.prepare() + return + } + player.playWhenReady = true + player.play() + mainHandler.postDelayed(finishClipStartWarmupRunnable, CLIP_START_WARMUP_MS) + } + + private fun finishClipStartWarmup() { + if (released) { + return + } + val restoreVolume = clipStartWarmupVolume ?: return + clipStartWarmupVolume = null + clipStartNeedsWarmup = false + mainHandler.removeCallbacks(finishClipStartWarmupRunnable) + player.seekTo(0L) + if (player.playbackState == Player.STATE_ENDED) { + player.prepare() + } + player.volume = restoreVolume + player.playWhenReady = true + player.play() + if (stopAtSentenceEnd) { + scheduleSentenceEndGuard() + } + } + + private fun cancelClipStartWarmup(restoreVolume: Boolean) { + playFromStartAfterReady = false + mainHandler.removeCallbacks(finishClipStartWarmupRunnable) + val volume = clipStartWarmupVolume ?: return + clipStartWarmupVolume = null + if (restoreVolume) { + player.volume = volume + } + } + private companion object { const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L const val NEXT_SENTENCE_TOLERANCE_MS = 150L const val IDLE_POLL_MS = 250L + const val CLIP_START_WARMUP_MS = 180L + const val CLIP_START_WARMUP_POSITION_MS = 120L } } diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerPlayerView.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerPlayerView.kt index a758541..fa53bff 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerPlayerView.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerPlayerView.kt @@ -16,6 +16,7 @@ class OralTrainerPlayerView @JvmOverloads constructor( ) : FrameLayout(context, attrs, defStyleAttr) { val playerView: PlayerView = PlayerView(context).apply { useController = false + setKeepContentOnPlayerReset(true) layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index 12642f5..8bb68af 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -449,8 +449,8 @@ class MainActivity : Activity() { override fun onReplaySentence() { testSession.stopDubbingIfNeeded() - testSession.refreshTestUi() controller.playCurrentSentenceAndStop() + testSession.refreshTestUi() } override fun onNextSentence() { diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt index 8b332eb..c31c493 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt @@ -57,7 +57,7 @@ internal class SentenceExplanationOverlay( setTextColor(SampleTheme.accentDeep) textSize = 14f setPadding(0, 8.dp, 0, 12.dp) - maxLines = 3 + maxLines = 5 } val message = TextView(activity).apply { text = "AI 正在讲解这句话…" diff --git a/sentence_api/ai_tutor.py b/sentence_api/ai_tutor.py index 29d50a7..2c58ae2 100644 --- a/sentence_api/ai_tutor.py +++ b/sentence_api/ai_tutor.py @@ -54,8 +54,8 @@ class AITutor: language_note = f"句子语言:{language.strip()}。" if language.strip() else "" system_prompt = ( "你是一位耐心的外语口语老师。请用简体中文讲解用户提供的影视/课程句子。" - "先给出自然的中文翻译,再解释关键词、常用搭配或语法要点,并简要说明这句话适合怎样跟读。" - "回答使用简明清晰的分段短文,总长度不超过 300 字,不要编造视频里没有的信息。" + "先给出自然的中文翻译,再解释关键词、常用搭配、语法要点,并简要说明这句话适合怎样跟读。" + "回答使用简明清晰的分段短文,总长度不超过 600 字,不要编造视频里没有的信息。" ) user_prompt = ( f"课程:{context}\n位置:{position}\n{language_note}句子:{sentence_text}"