fixed some bugs

This commit is contained in:
2026-09-13 09:42:39 +08:00
parent 5856832eca
commit 1499db0ed7
5 changed files with 230 additions and 20 deletions

View File

@@ -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,17 +73,40 @@ 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) {
if (clipStartWarmupVolume != null) {
finishClipStartWarmup()
} else {
onClippedSentenceEnded()
}
if (playbackState == Player.STATE_READY && unclippedDurationMs <= 0L) {
}
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) {
if (!isCurrentClip(sentence)) {
installSentenceClip(item, sentence, 0L)
} else {
player.seekTo(max(0L, startMs))
player.playWhenReady = false
}
playClippedSentenceFromStart()
} else {
cancelSentenceEndGuard()
player.seekTo(max(0L, startMs))
player.playWhenReady = true
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
}
}

View File

@@ -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)
}

View File

@@ -449,8 +449,8 @@ class MainActivity : Activity() {
override fun onReplaySentence() {
testSession.stopDubbingIfNeeded()
testSession.refreshTestUi()
controller.playCurrentSentenceAndStop()
testSession.refreshTestUi()
}
override fun onNextSentence() {

View File

@@ -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 正在讲解这句话…"

View File

@@ -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}"