This commit is contained in:
2026-09-12 15:55:12 +08:00
parent 8414f36cca
commit e2cc793bdb
4 changed files with 398 additions and 124 deletions

View File

@@ -11,7 +11,6 @@ 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.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
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
@@ -31,8 +30,10 @@ class OralTrainerController internal constructor(
private var lastSentenceIndex: Int? = null private var lastSentenceIndex: Int? = null
private var stopAtSentenceEnd = false private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int? = null private var stopAtSentenceEndIndex: Int? = null
private var boundaryMessage: PlayerMessage? = null private var activeBaseItemIndex: Int = -1
private var boundaryGuardToken = 0 private var activeSentenceWindow: SentenceWindow? = null
private var sentenceWindows: List<SentenceWindow> = emptyList()
private var sentencePlaylistBaseIndex: Int = -1
var config: PlayerConfig = initialConfig var config: PlayerConfig = initialConfig
private set private set
@@ -47,13 +48,20 @@ class OralTrainerController internal constructor(
if (released) { if (released) {
return return
} }
enforceSentenceBoundary()
notifySentenceIfChanged() notifySentenceIfChanged()
notifySnapshot() notifySnapshot()
mainHandler.postDelayed(this, 250L) mainHandler.postDelayed(this, 250L)
} }
} }
private data class SentenceWindow(
val baseItemIndex: Int,
val mediaId: String,
val sentenceIndex: Int,
val startMs: Long,
val endMs: Long,
)
init { init {
val mediaSourceFactory = DefaultMediaSourceFactory( val mediaSourceFactory = DefaultMediaSourceFactory(
StreamingCache.dataSourceFactory(appContext, sdkConfig) StreamingCache.dataSourceFactory(appContext, sdkConfig)
@@ -66,24 +74,34 @@ class OralTrainerController internal constructor(
exoPlayer.playWhenReady = initialConfig.autoPlay exoPlayer.playWhenReady = initialConfig.autoPlay
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) {
applySentenceEndForAnchor()
}
if (playbackState == Player.STATE_READY) {
refreshPlaybackWindow()
}
if (playbackState == Player.STATE_READY ||
playbackState == Player.STATE_ENDED
) {
refreshSentenceRepeatMode()
}
notifySnapshot() notifySnapshot()
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) { if (isPlaying) {
refreshSentenceBoundaryGuard() refreshPlaybackWindow()
} else { } else {
cancelSentenceBoundaryGuard() refreshPlaybackWindow()
} }
notifySnapshot() notifySnapshot()
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
lastSentenceIndex = null activeSentenceWindow = currentSentenceWindow()
cancelSentenceBoundaryGuard() lastSentenceIndex = activeSentenceWindow?.sentenceIndex
listeners.forEach { it.onMediaChanged(currentTrainingItem()) } listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
notifySentenceIfChanged(force = true) notifySentenceIfChanged(force = true)
refreshSentenceBoundaryGuard()
notifySnapshot() notifySnapshot()
} }
@@ -93,7 +111,9 @@ class OralTrainerController internal constructor(
reason: Int, reason: Int,
) { ) {
notifySentenceIfChanged(force = true) notifySentenceIfChanged(force = true)
refreshSentenceBoundaryGuard() activeSentenceWindow = currentSentenceWindow()
lastSentenceIndex = activeSentenceWindow?.sentenceIndex
refreshPlaybackWindow()
notifySnapshot() notifySnapshot()
} }
@@ -140,7 +160,11 @@ class OralTrainerController internal constructor(
lastSentenceIndex = null lastSentenceIndex = null
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs)) activeBaseItemIndex = safeIndex
activeSentenceWindow = null
sentenceWindows = emptyList()
sentencePlaylistBaseIndex = -1
player.setMediaItem(items[safeIndex].toMedia3Item(), max(0L, startPositionMs))
player.prepare() player.prepare()
player.playWhenReady = config.autoPlay player.playWhenReady = config.autoPlay
listeners.forEach { it.onMediaChanged(currentTrainingItem()) } listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
@@ -166,10 +190,14 @@ class OralTrainerController internal constructor(
return true return true
} }
mediaItems[index] = updatedItem mediaItems[index] = updatedItem
if (index == player.currentMediaItemIndex) { if (index == activeBaseItemIndex) {
listeners.forEach { it.onMediaChanged(updatedItem) } listeners.forEach { it.onMediaChanged(updatedItem) }
if (config.sentenceMode && !config.continuousPlayback && sentences.isNotEmpty()) {
installSentencePlaylist(index, activeSentenceWindow?.sentenceIndex ?: 0)
} else {
refreshPlaybackWindow()
}
notifySentenceIfChanged(force = true) notifySentenceIfChanged(force = true)
refreshSentenceBoundaryGuard()
notifySnapshot() notifySnapshot()
} }
return true return true
@@ -177,13 +205,12 @@ class OralTrainerController internal constructor(
fun play() { fun play() {
player.play() player.play()
refreshSentenceBoundaryGuard() refreshPlaybackWindow()
} }
fun pause() { fun pause() {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelSentenceBoundaryGuard()
player.pause() player.pause()
} }
@@ -198,19 +225,31 @@ class OralTrainerController internal constructor(
fun stop() { fun stop() {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelSentenceBoundaryGuard()
player.stop() player.stop()
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
player.seekTo(max(0L, positionMs)) val safePositionMs = max(0L, positionMs)
refreshSentenceBoundaryGuard() val anchor = sentenceAnchorAt(safePositionMs)
if (config.sentenceMode && !config.continuousPlayback && anchor != null) {
if (ensureSentenceWindow(anchor)) {
seekInSentenceWindow(anchor, safePositionMs)
} else {
player.seekTo(safePositionMs)
}
} else {
restoreFullWindow(safePositionMs)
player.seekTo(safePositionMs)
}
refreshPlaybackWindow()
notifySentenceIfChanged(force = true)
notifySnapshot()
} }
fun seekRelative(deltaMs: Long) { fun seekRelative(deltaMs: Long) {
seekTo(player.currentPosition + deltaMs) seekTo(playbackPositionMs() + deltaMs)
} }
fun rewind(stepMs: Long = config.defaultSeekStepMs) { fun rewind(stepMs: Long = config.defaultSeekStepMs) {
@@ -222,11 +261,11 @@ class OralTrainerController internal constructor(
} }
fun previousItem() { fun previousItem() {
player.seekToPreviousMediaItem() switchBaseItem(activeBaseItemIndex - 1)
} }
fun nextItem() { fun nextItem() {
player.seekToNextMediaItem() switchBaseItem(activeBaseItemIndex + 1)
} }
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
@@ -240,44 +279,50 @@ class OralTrainerController internal constructor(
fun setLoopMode(loopMode: LoopMode) { fun setLoopMode(loopMode: LoopMode) {
this.loopMode = loopMode this.loopMode = loopMode
player.repeatMode = when (loopMode) { refreshSentenceRepeatMode()
LoopMode.OFF -> Player.REPEAT_MODE_OFF
LoopMode.ONE -> Player.REPEAT_MODE_ONE
LoopMode.ALL -> Player.REPEAT_MODE_ALL
}
} }
fun setSentenceMode(enabled: Boolean) { fun setSentenceMode(enabled: Boolean) {
config = config.copy(sentenceMode = enabled) config = config.copy(sentenceMode = enabled)
refreshPlaybackWindow()
} }
fun setContinuousPlayback(enabled: Boolean) { fun setContinuousPlayback(enabled: Boolean) {
config = config.copy(continuousPlayback = enabled) config = config.copy(continuousPlayback = enabled)
refreshSentenceBoundaryGuard() refreshPlaybackWindow()
} }
fun playCurrentSentenceAndStop() { fun playCurrentSentenceAndStop() {
val sentence = currentSentence() ?: return val sentence = activeSentenceWindow?.let { window ->
currentTrainingItem()?.sentences?.getOrNull(window.sentenceIndex)
} ?: currentSentence() ?: return
playSentenceAndStop(sentence.startMs, sentence.index) playSentenceAndStop(sentence.startMs, sentence.index)
} }
/** 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 sentence = currentTrainingItem()?.sentences?.getOrNull(sentenceIndex)
stopAtSentenceEnd = true stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentenceIndex stopAtSentenceEndIndex = sentenceIndex
if (sentence != null) {
if (!ensureSentenceWindow(sentence)) {
player.seekTo(max(0L, startMs)) player.seekTo(max(0L, startMs))
}
}
player.play() player.play()
refreshSentenceBoundaryGuard() refreshPlaybackWindow()
} }
fun currentTrainingItem(): TrainingMediaItem? { fun currentTrainingItem(): TrainingMediaItem? {
val index = player.currentMediaItemIndex return mediaItems.getOrNull(activeBaseItemIndex)
return mediaItems.getOrNull(index)
} }
fun currentSentence(): SentenceBoundary? { fun currentSentence(): SentenceBoundary? {
val item = currentTrainingItem() ?: return null val item = currentTrainingItem() ?: return null
val position = player.currentPosition if (isSentencePlaylistActive()) {
return item.sentences.getOrNull(player.currentMediaItemIndex)
}
val position = playbackPositionMs()
return item.sentences.lastOrNull { sentence -> return item.sentences.lastOrNull { sentence ->
position >= sentence.startMs && position < sentence.endMs position >= sentence.startMs && position < sentence.endMs
} }
@@ -288,7 +333,12 @@ class OralTrainerController internal constructor(
if (item.sentences.isEmpty()) { if (item.sentences.isEmpty()) {
return false return false
} }
val position = player.currentPosition if (isSentencePlaylistActive()) {
val index = (player.currentMediaItemIndex - 1).coerceAtLeast(0)
seekTo(item.sentences[index].startMs)
return true
}
val position = playbackPositionMs()
val target = item.sentences val target = item.sentences
.asReversed() .asReversed()
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS } .firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
@@ -302,7 +352,13 @@ class OralTrainerController internal constructor(
if (item.sentences.isEmpty()) { if (item.sentences.isEmpty()) {
return false return false
} }
val position = player.currentPosition if (isSentencePlaylistActive()) {
val index = (player.currentMediaItemIndex + 1)
.takeIf { it < item.sentences.size } ?: return false
seekTo(item.sentences[index].startMs)
return true
}
val position = playbackPositionMs()
val target = item.sentences.firstOrNull { val target = item.sentences.firstOrNull {
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
} ?: return false } ?: return false
@@ -362,12 +418,17 @@ class OralTrainerController internal constructor(
fun snapshot(): PlaybackSnapshot { fun snapshot(): PlaybackSnapshot {
val item = currentTrainingItem() val item = currentTrainingItem()
val duration = player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L val window = activeSentenceWindow
val duration = if (window != null) {
window.endMs
} else {
player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L
}
return PlaybackSnapshot( return PlaybackSnapshot(
mediaId = item?.id, mediaId = item?.id,
positionMs = max(0L, player.currentPosition), positionMs = playbackPositionMs(),
durationMs = duration, durationMs = duration,
bufferedPositionMs = max(0L, player.bufferedPosition), bufferedPositionMs = playbackBufferedPositionMs(),
isPlaying = player.isPlaying, isPlaying = player.isPlaying,
playbackState = player.playbackState.toOralTrainerState(), playbackState = player.playbackState.toOralTrainerState(),
playbackSpeed = player.playbackParameters.speed, playbackSpeed = player.playbackParameters.speed,
@@ -380,7 +441,6 @@ class OralTrainerController internal constructor(
return return
} }
released = true released = true
cancelSentenceBoundaryGuard()
mainHandler.removeCallbacks(ticker) mainHandler.removeCallbacks(ticker)
listeners.clear() listeners.clear()
player.release() player.release()
@@ -404,101 +464,42 @@ class OralTrainerController internal constructor(
lastSentenceIndex = index lastSentenceIndex = index
listeners.forEach { it.onSentenceChanged(sentence) } listeners.forEach { it.onSentenceChanged(sentence) }
if (changed && !config.continuousPlayback && !stopAtSentenceEnd) { if (changed && !config.continuousPlayback && !stopAtSentenceEnd) {
refreshSentenceBoundaryGuard() refreshPlaybackWindow()
} }
} }
private fun enforceSentenceBoundary() { private fun refreshPlaybackWindow() {
if (!config.sentenceMode || !player.isPlaying) { val item = currentTrainingItem()
return
}
val item = currentTrainingItem() ?: return
if (item.sentences.isEmpty()) {
return
}
val position = player.currentPosition
if (stopAtSentenceEnd) {
val index = stopAtSentenceEndIndex ?: return
val sentence = item.sentences.getOrNull(index) ?: return
if (position >= sentence.endMs) {
applySentenceEndAction(sentence)
}
return
}
if (config.continuousPlayback) {
return
}
val anchorIndex = lastSentenceIndex ?: currentSentence()?.index ?: return
val anchor = item.sentences.getOrNull(anchorIndex) ?: return
if (position >= anchor.endMs) {
applySentenceEndAction(anchor)
}
}
private fun refreshSentenceBoundaryGuard() {
cancelSentenceBoundaryGuard()
if (released || !config.sentenceMode) { if (released || !config.sentenceMode) {
return restoreFullWindow(playbackPositionMs())
}
if (!player.isPlaying && !player.playWhenReady) {
return
}
val item = currentTrainingItem() ?: return
if (item.sentences.isEmpty()) {
return return
} }
val sentence = when { if (item == null || item.sentences.isEmpty()) {
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return) restoreFullWindow(playbackPositionMs())
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 return
} }
val token = ++boundaryGuardToken if (config.continuousPlayback && !stopAtSentenceEnd) {
boundaryMessage = player.createMessage { _, _ -> restoreFullWindow(playbackPositionMs())
if (token != boundaryGuardToken || released) { return
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() { val sentence = sentenceForPlaybackWindow(item) ?: return
boundaryGuardToken++ if (!player.isPlaying && !player.playWhenReady && activeSentenceWindow == null) {
boundaryMessage?.let { message -> return
runCatching { message.cancel() }
} }
boundaryMessage = null
ensureSentenceWindow(sentence)
refreshSentenceRepeatMode()
} }
private fun applySentenceEndAction(sentence: SentenceBoundary) { private fun applySentenceEndAction(sentence: SentenceBoundary) {
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = null stopAtSentenceEndIndex = null
cancelSentenceBoundaryGuard()
player.pause() player.pause()
player.seekTo(sentence.startMs) player.seekTo(0L)
lastSentenceIndex = sentence.index lastSentenceIndex = sentence.index
notifySentenceIfChanged(force = true) notifySentenceIfChanged(force = true)
notifySnapshot() notifySnapshot()
@@ -507,20 +508,232 @@ class OralTrainerController internal constructor(
if (config.continuousPlayback) { if (config.continuousPlayback) {
return return
} }
// Loop the current sentence without leaking into the next one. player.pause()
player.seekTo(sentence.startMs) // 当前 MediaItem 已 clipped 到 [startMs, endMs),句首就是窗口内的 0ms。
player.seekTo(0L)
if (player.playbackState == Player.STATE_ENDED) {
player.prepare()
}
player.play()
lastSentenceIndex = sentence.index lastSentenceIndex = sentence.index
notifySentenceIfChanged(force = true) notifySentenceIfChanged(force = true)
refreshSentenceBoundaryGuard() refreshPlaybackWindow()
notifySnapshot() notifySnapshot()
} }
private fun TrainingMediaItem.toMedia3Item(): MediaItem { private fun applySentenceEndForAnchor() {
if (!config.sentenceMode || config.continuousPlayback) {
return
}
val item = currentTrainingItem()
val index = currentSentenceIndex() ?: lastSentenceIndex ?: return
val sentence = item?.sentences?.getOrNull(index) ?: return
applySentenceEndAction(sentence)
}
private fun sentenceAnchorAt(positionMs: Long): SentenceBoundary? {
val item = currentTrainingItem() ?: return null
return item.sentences.lastOrNull { positionMs >= it.startMs && positionMs < it.endMs }
?: item.sentences.lastOrNull { it.startMs <= positionMs }
}
private fun sentenceForPlaybackWindow(item: TrainingMediaItem?): SentenceBoundary? {
if (item == null || item.sentences.isEmpty()) {
return null
}
return when {
!config.sentenceMode -> null
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null)
config.continuousPlayback -> null
else -> {
val index = currentSentenceIndex()
?: lastSentenceIndex
?: currentSentence()?.index
?: sentenceAnchorAt(playbackPositionMs())?.index
item.sentences.getOrNull(index ?: return null)
}
}
}
private fun playbackPositionMs(): Long {
val window = activeSentenceWindow ?: return max(0L, player.currentPosition)
val lengthMs = (window.endMs - window.startMs).coerceAtLeast(0L)
val clippedPositionMs = player.currentPosition.coerceIn(0L, lengthMs)
return window.startMs + clippedPositionMs
}
private fun playbackBufferedPositionMs(): Long {
val window = activeSentenceWindow ?: return max(0L, player.bufferedPosition)
val lengthMs = (window.endMs - window.startMs).coerceAtLeast(0L)
val clippedBufferedMs = player.bufferedPosition.coerceIn(0L, lengthMs)
return window.startMs + clippedBufferedMs
}
private fun ensureSentenceWindow(target: SentenceBoundary): Boolean {
if (released) {
return false
}
if (sentencePlaylistBaseIndex != activeBaseItemIndex &&
player.playbackState in listOf(Player.STATE_READY, Player.STATE_ENDED)
) {
installSentencePlaylist(activeBaseItemIndex, target.index)
return true
}
if (!isSentencePlaylistActive()) {
return false
}
val desired = sentenceWindows.getOrNull(target.index) ?: return false
if (player.currentMediaItemIndex != target.index) {
player.seekTo(target.index, 0L)
}
activeSentenceWindow = desired
return true
}
private fun installSentencePlaylist(baseIndex: Int, startIndex: Int) {
if (released) return
val baseItem = mediaItems.getOrNull(baseIndex) ?: return
val sentences = baseItem.sentences
if (sentences.isEmpty()) return
sentenceWindows = sentences.mapIndexed { index, sentence ->
SentenceWindow(
baseItemIndex = baseIndex,
mediaId = baseItem.id,
sentenceIndex = index,
startMs = sentence.startMs,
endMs = sentence.endMs,
)
}
sentencePlaylistBaseIndex = baseIndex
activeBaseItemIndex = baseIndex
val safeStartIndex = startIndex.coerceIn(0, sentenceWindows.lastIndex)
activeSentenceWindow = sentenceWindows[safeStartIndex]
lastSentenceIndex = safeStartIndex
val playWhenReady = player.playWhenReady
player.setMediaItems(
sentenceWindows.map { window ->
baseItem.toMedia3Item(window.startMs, window.endMs)
},
safeStartIndex,
0L,
)
player.prepare()
player.playWhenReady = playWhenReady
refreshSentenceRepeatMode()
}
private fun currentSentenceWindow(): SentenceWindow? {
if (!isSentencePlaylistActive()) return null
return sentenceWindows.getOrNull(player.currentMediaItemIndex)
}
private fun currentSentenceIndex(): Int? {
if (!isSentencePlaylistActive()) return null
return player.currentMediaItemIndex.takeIf { it in sentenceWindows.indices }
}
private fun isSentencePlaylistActive(): Boolean {
return sentencePlaylistBaseIndex == activeBaseItemIndex &&
sentenceWindows.isNotEmpty() &&
player.currentMediaItemIndex in sentenceWindows.indices
}
private fun refreshSentenceRepeatMode() {
if (!isSentencePlaylistActive()) {
player.repeatMode = when (loopMode) {
LoopMode.OFF -> Player.REPEAT_MODE_OFF
LoopMode.ONE -> Player.REPEAT_MODE_ONE
LoopMode.ALL -> Player.REPEAT_MODE_ALL
}
return
}
player.repeatMode = when {
stopAtSentenceEnd || !config.continuousPlayback -> Player.REPEAT_MODE_ONE
loopMode == LoopMode.OFF -> Player.REPEAT_MODE_OFF
else -> Player.REPEAT_MODE_ALL
}
}
private fun seekInSentenceWindow(target: SentenceBoundary, originalPositionMs: Long) {
val relativePositionMs = (originalPositionMs - target.startMs).coerceIn(
0L,
(target.endMs - target.startMs).coerceAtLeast(0L),
)
player.seekTo(relativePositionMs)
}
private fun restoreFullWindow(positionMs: Long) {
val baseIndex = activeBaseItemIndex
val baseItem = mediaItems.getOrNull(baseIndex) ?: return
if (sentencePlaylistBaseIndex == -1 &&
activeSentenceWindow == null &&
baseItem.id == player.currentMediaItem?.mediaId
) {
return
}
val playWhenReady = player.playWhenReady
activeSentenceWindow = null
sentenceWindows = emptyList()
sentencePlaylistBaseIndex = -1
player.setMediaItem(baseItem.toMedia3Item(), max(0L, positionMs))
player.prepare()
player.playWhenReady = playWhenReady
player.repeatMode = when (loopMode) {
LoopMode.OFF -> Player.REPEAT_MODE_OFF
LoopMode.ONE -> Player.REPEAT_MODE_ONE
LoopMode.ALL -> Player.REPEAT_MODE_ALL
}
}
private fun switchBaseItem(targetIndex: Int) {
if (targetIndex !in mediaItems.indices) {
return
}
activeBaseItemIndex = targetIndex
activeSentenceWindow = null
sentenceWindows = emptyList()
sentencePlaylistBaseIndex = -1
lastSentenceIndex = null
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
if (config.sentenceMode && mediaItems[targetIndex].sentences.isNotEmpty()) {
installSentencePlaylist(targetIndex, 0)
} else {
player.setMediaItem(mediaItems[targetIndex].toMedia3Item(), 0L)
player.prepare()
player.repeatMode = when (loopMode) {
LoopMode.OFF -> Player.REPEAT_MODE_OFF
LoopMode.ONE -> Player.REPEAT_MODE_ONE
LoopMode.ALL -> Player.REPEAT_MODE_ALL
}
}
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
notifySentenceIfChanged(force = true)
notifySnapshot()
}
private fun TrainingMediaItem.toMedia3Item(
clippingStartPositionMs: Long = 0L,
clippingEndPositionMs: Long? = null,
): MediaItem {
val builder = MediaItem.Builder() val builder = MediaItem.Builder()
.setMediaId(id) .setMediaId(id)
.setUri(uri) .setUri(uri)
mimeType?.let(builder::setMimeType) mimeType?.let(builder::setMimeType)
customCacheKey?.let(builder::setCustomCacheKey) customCacheKey?.let(builder::setCustomCacheKey)
if (clippingEndPositionMs != null) {
builder.setClippingConfiguration(
MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(max(0L, clippingStartPositionMs))
.setEndPositionMs(max(0L, clippingEndPositionMs))
.setStartsAtKeyFrame(false)
.build()
)
}
subtitleUri?.let { uri -> subtitleUri?.let { uri ->
builder.setSubtitleConfigurations( builder.setSubtitleConfigurations(
listOf( listOf(

View File

@@ -21,6 +21,7 @@ import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import kotlin.math.roundToInt
class MainActivity : Activity() { class MainActivity : Activity() {
private lateinit var sdk: OralTrainerSdk private lateinit var sdk: OralTrainerSdk
@@ -362,12 +363,43 @@ class MainActivity : Activity() {
if (!progressBarDragging) { if (!progressBarDragging) {
bindings.timeText.text = bindings.timeText.text =
"${PlaybackFormat.time(snapshot.positionMs)} / ${PlaybackFormat.time(snapshot.durationMs)}" "${PlaybackFormat.time(snapshot.positionMs)} / ${PlaybackFormat.time(snapshot.durationMs)}"
bindings.progressBar.progress = PlaybackFormat.progress(snapshot) bindings.progressBar.progress = if (controller.config.continuousPlayback) {
PlaybackFormat.progress(snapshot)
} else {
sentenceProgress(snapshot)
}
} }
bindings.speedText.text = PlaybackFormat.speed(snapshot.playbackSpeed) bindings.speedText.text = PlaybackFormat.speed(snapshot.playbackSpeed)
bindings.statusText.text = PlaybackFormat.status(snapshot) bindings.statusText.text = PlaybackFormat.status(snapshot)
} }
private fun sentenceCount(): Int {
return controller.currentTrainingItem()?.sentences?.size?.takeIf { it > 0 }
?: screenState.currentSentenceCount
}
private fun sentenceProgress(snapshot: PlaybackSnapshot): Int {
val count = sentenceCount()
val index = snapshot.sentenceIndex ?: return 0
if (count <= 1) return 0
return (index * PROGRESS_MAX.toFloat() / (count - 1)).roundToInt()
.coerceIn(0, PROGRESS_MAX)
}
private fun sentenceForProgress(progress: Int): Triple<Int, SentenceBoundary?, Int> {
val sentences = controller.currentTrainingItem()?.sentences.orEmpty()
if (sentences.isEmpty()) {
return Triple(0, null, 0)
}
val index = if (sentences.size == 1) {
0
} else {
(progress.toFloat() / PROGRESS_MAX * (sentences.size - 1)).roundToInt()
.coerceIn(0, sentences.lastIndex)
}
return Triple(index, sentences.getOrNull(index), sentences.size)
}
private fun applySentence(sentence: SentenceBoundary?) { private fun applySentence(sentence: SentenceBoundary?) {
if (!bindings.hasPlayerChrome) return if (!bindings.hasPlayerChrome) return
if (sentence == null) { if (sentence == null) {
@@ -399,9 +431,21 @@ class MainActivity : Activity() {
override fun onSeekProgressPreview(progress: Int) { override fun onSeekProgressPreview(progress: Int) {
if (!bindings.hasPlayerChrome) return if (!bindings.hasPlayerChrome) return
if (controller.config.continuousPlayback) {
val duration = controller.snapshot().durationMs val duration = controller.snapshot().durationMs
bindings.timeText.text = bindings.timeText.text =
"${PlaybackFormat.time(PlaybackFormat.seekPosition(progress, duration))} / ${PlaybackFormat.time(duration)}" "${PlaybackFormat.time(PlaybackFormat.seekPosition(progress, duration))} / " +
PlaybackFormat.time(duration)
return
}
val (index, sentence, count) = sentenceForProgress(progress)
bindings.timeText.text =
if (sentence == null) {
"暂无句子边界"
} else {
"第 ${index + 1}/$count 句 " +
"${PlaybackFormat.time(sentence.startMs)}-${PlaybackFormat.time(sentence.endMs)}"
}
} }
override fun onSeekStart() { override fun onSeekStart() {
@@ -410,9 +454,13 @@ class MainActivity : Activity() {
override fun onSeekStop(progress: Int) { override fun onSeekStop(progress: Int) {
progressBarDragging = false progressBarDragging = false
controller.seekTo( if (controller.config.continuousPlayback) {
PlaybackFormat.seekPosition(progress, controller.snapshot().durationMs), val duration = controller.snapshot().durationMs
) controller.seekTo(PlaybackFormat.seekPosition(progress, duration))
return
}
val (_, sentence, _) = sentenceForProgress(progress)
sentence?.let { controller.seekTo(it.startMs) }
} }
override fun onContinuousPlaybackChanged(enabled: Boolean) { override fun onContinuousPlaybackChanged(enabled: Boolean) {

View File

@@ -233,6 +233,7 @@ struct SentenceExplanationSheet: View {
let courseTitle: String let courseTitle: String
let sentence: SentenceBoundary let sentence: SentenceBoundary
@Environment(\.dismiss) private var dismiss
@State private var explanation = "" @State private var explanation = ""
@State private var errorMessage: String? @State private var errorMessage: String?
@@ -264,6 +265,18 @@ struct SentenceExplanationSheet: View {
.background(Color.white.ignoresSafeArea()) .background(Color.white.ignoresSafeArea())
.navigationTitle("句子讲解") .navigationTitle("句子讲解")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(Theme.textMuted)
}
.accessibilityLabel("关闭句子讲解")
}
}
} }
.task { .task {
guard explanation.isEmpty, errorMessage == nil else { return } guard explanation.isEmpty, errorMessage == nil else { return }