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.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.max import kotlin.math.max
@@ -34,6 +37,11 @@ class OralTrainerController internal constructor(
private var clippedSentence: SentenceBoundary? = null private var clippedSentence: SentenceBoundary? = null
private var updatingPlaybackWindow = false private var updatingPlaybackWindow = false
private var unclippedDurationMs = -1L 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 var config: PlayerConfig = initialConfig
private set private set
@@ -48,6 +56,7 @@ class OralTrainerController internal constructor(
if (released) { if (released) {
return return
} }
enforceStopAtSentenceEnd()
notifySentenceIfChanged() notifySentenceIfChanged()
notifySnapshot() notifySnapshot()
mainHandler.postDelayed(this, IDLE_POLL_MS) mainHandler.postDelayed(this, IDLE_POLL_MS)
@@ -64,17 +73,40 @@ class OralTrainerController internal constructor(
.also { exoPlayer -> .also { exoPlayer ->
exoPlayer.repeatMode = Player.REPEAT_MODE_ALL exoPlayer.repeatMode = Player.REPEAT_MODE_ALL
exoPlayer.playWhenReady = initialConfig.autoPlay 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 { exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED) {
if (clipStartWarmupVolume != null) {
finishClipStartWarmup()
} else {
onClippedSentenceEnded() 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 val duration = player.duration
if (duration != C.TIME_UNSET && clippedSentence == null) { if (duration != C.TIME_UNSET && clippedSentence == null) {
unclippedDurationMs = duration unclippedDurationMs = duration
} }
} }
}
notifySnapshot() notifySnapshot()
} }
@@ -148,6 +180,8 @@ class OralTrainerController internal constructor(
lastSentenceIndex = null lastSentenceIndex = null
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
baseItemIndex = safeIndex baseItemIndex = safeIndex
clippedSentence = null clippedSentence = null
unclippedDurationMs = -1L unclippedDurationMs = -1L
@@ -193,12 +227,18 @@ class OralTrainerController internal constructor(
fun play() { fun play() {
syncPlaybackWindow() syncPlaybackWindow()
if (clippedSentence != null && !player.isPlaying && shouldWarmClipStart()) {
playClippedSentenceFromStart()
return
}
player.play() player.play()
} }
fun pause() { fun pause() {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
player.pause() player.pause()
applySentenceRepeatMode() applySentenceRepeatMode()
} }
@@ -214,12 +254,16 @@ class OralTrainerController internal constructor(
fun stop() { fun stop() {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
player.stop() player.stop()
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
val safePositionMs = max(0L, positionMs) val safePositionMs = max(0L, positionMs)
lockSentenceAt(safePositionMs) lockSentenceAt(safePositionMs)
syncPlaybackWindow(safePositionMs) syncPlaybackWindow(safePositionMs)
@@ -283,17 +327,25 @@ class OralTrainerController internal constructor(
/** Seek to a known sentence and stop automatically when that sentence ends. */ /** Seek to a known sentence and stop automatically when that sentence ends. */
fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) { fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) {
val item = currentTrainingItem()
val sentence = item?.sentences?.getOrNull(sentenceIndex)
cancelClipStartWarmup(restoreVolume = true)
stopAtSentenceEnd = true stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentenceIndex stopAtSentenceEndIndex = sentenceIndex
lastSentenceIndex = sentenceIndex lastSentenceIndex = sentenceIndex
val item = currentTrainingItem() player.playWhenReady = false
val sentence = item?.sentences?.getOrNull(sentenceIndex)
if (item != null && sentence != null) { if (item != null && sentence != null) {
if (!isCurrentClip(sentence)) {
installSentenceClip(item, sentence, 0L) installSentenceClip(item, sentence, 0L)
} else { player.playWhenReady = false
player.seekTo(max(0L, startMs))
} }
playClippedSentenceFromStart()
} else {
cancelSentenceEndGuard()
player.seekTo(max(0L, startMs))
player.playWhenReady = true
player.play() player.play()
}
notifySentenceIfChanged(force = true) notifySentenceIfChanged(force = true)
notifySnapshot() notifySnapshot()
} }
@@ -429,6 +481,8 @@ class OralTrainerController internal constructor(
return return
} }
released = true released = true
cancelClipStartWarmup(restoreVolume = false)
cancelSentenceEndGuard()
mainHandler.removeCallbacks(ticker) mainHandler.removeCallbacks(ticker)
listeners.clear() listeners.clear()
player.release() player.release()
@@ -498,17 +552,28 @@ class OralTrainerController internal constructor(
return return
} }
rememberUnclippedDuration() rememberUnclippedDuration()
cancelSentenceEndGuard()
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L) val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
val startPositionMs = relativePositionMs.coerceIn(0L, lengthMs) val startPositionMs = relativePositionMs.coerceIn(0L, lengthMs)
val playWhenReady = player.playWhenReady val playWhenReady = player.playWhenReady
val warmFromStart = startPositionMs <= CLIP_START_WARMUP_POSITION_MS
clipStartNeedsWarmup = warmFromStart
updatingPlaybackWindow = true updatingPlaybackWindow = true
clippedSentence = sentence clippedSentence = sentence
lastSentenceIndex = sentence.index lastSentenceIndex = sentence.index
player.setMediaItem(item.toMedia3Item(sentence.startMs, sentence.endMs), startPositionMs) player.setMediaItem(item.toMedia3Item(sentence.startMs, sentence.endMs), startPositionMs)
player.prepare() player.prepare()
player.playWhenReady = playWhenReady player.playWhenReady = false
applySentenceRepeatMode() applySentenceRepeatMode()
updatingPlaybackWindow = false updatingPlaybackWindow = false
if (playWhenReady) {
if (warmFromStart) {
playClippedSentenceFromStart()
} else {
player.playWhenReady = true
player.play()
}
}
} }
private fun restoreUnclipped(positionMs: Long) { private fun restoreUnclipped(positionMs: Long) {
@@ -522,6 +587,7 @@ class OralTrainerController internal constructor(
val playWhenReady = player.playWhenReady val playWhenReady = player.playWhenReady
updatingPlaybackWindow = true updatingPlaybackWindow = true
clippedSentence = null clippedSentence = null
clipStartNeedsWarmup = false
player.setMediaItems( player.setMediaItems(
mediaItems.map { it.toMedia3Item() }, mediaItems.map { it.toMedia3Item() },
baseItemIndex.coerceIn(0, mediaItems.lastIndex), baseItemIndex.coerceIn(0, mediaItems.lastIndex),
@@ -545,16 +611,16 @@ class OralTrainerController internal constructor(
private fun onClippedSentenceEnded() { private fun onClippedSentenceEnded() {
val sentence = clippedSentence ?: return val sentence = clippedSentence ?: return
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) {
handleStopAtSentenceEnd(sentence)
return
}
clipStartNeedsWarmup = false
player.seekTo(0L) player.seekTo(0L)
if (player.playbackState == Player.STATE_ENDED) { if (player.playbackState == Player.STATE_ENDED) {
player.prepare() player.prepare()
} }
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { if (!config.continuousPlayback) {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
player.pause()
applySentenceRepeatMode()
} else if (!config.continuousPlayback) {
player.play() player.play()
} }
lastSentenceIndex = sentence.index lastSentenceIndex = sentence.index
@@ -562,6 +628,63 @@ class OralTrainerController internal constructor(
notifySnapshot() 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) { private fun switchBaseItem(targetIndex: Int) {
if (targetIndex !in mediaItems.indices) { if (targetIndex !in mediaItems.indices) {
return return
@@ -570,6 +693,8 @@ class OralTrainerController internal constructor(
lastSentenceIndex = null lastSentenceIndex = null
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
clippedSentence = null clippedSentence = null
unclippedDurationMs = -1L unclippedDurationMs = -1L
val item = mediaItems[targetIndex] val item = mediaItems[targetIndex]
@@ -694,9 +819,93 @@ class OralTrainerController internal constructor(
return CancellableAssessment {} 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 { private companion object {
const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L
const val NEXT_SENTENCE_TOLERANCE_MS = 150L const val NEXT_SENTENCE_TOLERANCE_MS = 150L
const val IDLE_POLL_MS = 250L 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) { ) : FrameLayout(context, attrs, defStyleAttr) {
val playerView: PlayerView = PlayerView(context).apply { val playerView: PlayerView = PlayerView(context).apply {
useController = false useController = false
setKeepContentOnPlayerReset(true)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
} }

View File

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

View File

@@ -57,7 +57,7 @@ internal class SentenceExplanationOverlay(
setTextColor(SampleTheme.accentDeep) setTextColor(SampleTheme.accentDeep)
textSize = 14f textSize = 14f
setPadding(0, 8.dp, 0, 12.dp) setPadding(0, 8.dp, 0, 12.dp)
maxLines = 3 maxLines = 5
} }
val message = TextView(activity).apply { val message = TextView(activity).apply {
text = "AI 正在讲解这句话…" text = "AI 正在讲解这句话…"

View File

@@ -54,8 +54,8 @@ class AITutor:
language_note = f"句子语言:{language.strip()}。" if language.strip() else "" language_note = f"句子语言:{language.strip()}。" if language.strip() else ""
system_prompt = ( system_prompt = (
"你是一位耐心的外语口语老师。请用简体中文讲解用户提供的影视/课程句子。" "你是一位耐心的外语口语老师。请用简体中文讲解用户提供的影视/课程句子。"
"先给出自然的中文翻译,再解释关键词、常用搭配或语法要点,并简要说明这句话适合怎样跟读。" "先给出自然的中文翻译,再解释关键词、常用搭配、语法要点,并简要说明这句话适合怎样跟读。"
"回答使用简明清晰的分段短文,总长度不超过 300 字,不要编造视频里没有的信息。" "回答使用简明清晰的分段短文,总长度不超过 600 字,不要编造视频里没有的信息。"
) )
user_prompt = ( user_prompt = (
f"课程:{context}\n位置:{position}\n{language_note}句子:{sentence_text}" f"课程:{context}\n位置:{position}\n{language_note}句子:{sentence_text}"