fff
This commit is contained in:
@@ -11,7 +11,6 @@ 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
|
||||
@@ -31,8 +30,10 @@ 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
|
||||
private var activeBaseItemIndex: Int = -1
|
||||
private var activeSentenceWindow: SentenceWindow? = null
|
||||
private var sentenceWindows: List<SentenceWindow> = emptyList()
|
||||
private var sentencePlaylistBaseIndex: Int = -1
|
||||
|
||||
var config: PlayerConfig = initialConfig
|
||||
private set
|
||||
@@ -47,13 +48,20 @@ class OralTrainerController internal constructor(
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
enforceSentenceBoundary()
|
||||
notifySentenceIfChanged()
|
||||
notifySnapshot()
|
||||
mainHandler.postDelayed(this, 250L)
|
||||
}
|
||||
}
|
||||
|
||||
private data class SentenceWindow(
|
||||
val baseItemIndex: Int,
|
||||
val mediaId: String,
|
||||
val sentenceIndex: Int,
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
)
|
||||
|
||||
init {
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(
|
||||
StreamingCache.dataSourceFactory(appContext, sdkConfig)
|
||||
@@ -66,24 +74,34 @@ class OralTrainerController internal constructor(
|
||||
exoPlayer.playWhenReady = initialConfig.autoPlay
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
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()
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
refreshSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
} else {
|
||||
cancelSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
notifySnapshot()
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
lastSentenceIndex = null
|
||||
cancelSentenceBoundaryGuard()
|
||||
activeSentenceWindow = currentSentenceWindow()
|
||||
lastSentenceIndex = activeSentenceWindow?.sentenceIndex
|
||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||
notifySentenceIfChanged(force = true)
|
||||
refreshSentenceBoundaryGuard()
|
||||
notifySnapshot()
|
||||
}
|
||||
|
||||
@@ -93,7 +111,9 @@ class OralTrainerController internal constructor(
|
||||
reason: Int,
|
||||
) {
|
||||
notifySentenceIfChanged(force = true)
|
||||
refreshSentenceBoundaryGuard()
|
||||
activeSentenceWindow = currentSentenceWindow()
|
||||
lastSentenceIndex = activeSentenceWindow?.sentenceIndex
|
||||
refreshPlaybackWindow()
|
||||
notifySnapshot()
|
||||
}
|
||||
|
||||
@@ -140,7 +160,11 @@ class OralTrainerController internal constructor(
|
||||
lastSentenceIndex = null
|
||||
stopAtSentenceEnd = false
|
||||
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.playWhenReady = config.autoPlay
|
||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||
@@ -166,10 +190,14 @@ class OralTrainerController internal constructor(
|
||||
return true
|
||||
}
|
||||
mediaItems[index] = updatedItem
|
||||
if (index == player.currentMediaItemIndex) {
|
||||
if (index == activeBaseItemIndex) {
|
||||
listeners.forEach { it.onMediaChanged(updatedItem) }
|
||||
if (config.sentenceMode && !config.continuousPlayback && sentences.isNotEmpty()) {
|
||||
installSentencePlaylist(index, activeSentenceWindow?.sentenceIndex ?: 0)
|
||||
} else {
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
notifySentenceIfChanged(force = true)
|
||||
refreshSentenceBoundaryGuard()
|
||||
notifySnapshot()
|
||||
}
|
||||
return true
|
||||
@@ -177,13 +205,12 @@ class OralTrainerController internal constructor(
|
||||
|
||||
fun play() {
|
||||
player.play()
|
||||
refreshSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.pause()
|
||||
}
|
||||
|
||||
@@ -198,19 +225,31 @@ class OralTrainerController internal constructor(
|
||||
fun stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.stop()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.seekTo(max(0L, positionMs))
|
||||
refreshSentenceBoundaryGuard()
|
||||
val safePositionMs = max(0L, positionMs)
|
||||
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) {
|
||||
seekTo(player.currentPosition + deltaMs)
|
||||
seekTo(playbackPositionMs() + deltaMs)
|
||||
}
|
||||
|
||||
fun rewind(stepMs: Long = config.defaultSeekStepMs) {
|
||||
@@ -222,11 +261,11 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
fun previousItem() {
|
||||
player.seekToPreviousMediaItem()
|
||||
switchBaseItem(activeBaseItemIndex - 1)
|
||||
}
|
||||
|
||||
fun nextItem() {
|
||||
player.seekToNextMediaItem()
|
||||
switchBaseItem(activeBaseItemIndex + 1)
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
@@ -240,44 +279,50 @@ class OralTrainerController internal constructor(
|
||||
|
||||
fun setLoopMode(loopMode: LoopMode) {
|
||||
this.loopMode = loopMode
|
||||
player.repeatMode = when (loopMode) {
|
||||
LoopMode.OFF -> Player.REPEAT_MODE_OFF
|
||||
LoopMode.ONE -> Player.REPEAT_MODE_ONE
|
||||
LoopMode.ALL -> Player.REPEAT_MODE_ALL
|
||||
}
|
||||
refreshSentenceRepeatMode()
|
||||
}
|
||||
|
||||
fun setSentenceMode(enabled: Boolean) {
|
||||
config = config.copy(sentenceMode = enabled)
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
|
||||
fun setContinuousPlayback(enabled: Boolean) {
|
||||
config = config.copy(continuousPlayback = enabled)
|
||||
refreshSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
|
||||
fun playCurrentSentenceAndStop() {
|
||||
val sentence = currentSentence() ?: return
|
||||
val sentence = activeSentenceWindow?.let { window ->
|
||||
currentTrainingItem()?.sentences?.getOrNull(window.sentenceIndex)
|
||||
} ?: currentSentence() ?: return
|
||||
playSentenceAndStop(sentence.startMs, sentence.index)
|
||||
}
|
||||
|
||||
/** Seek to a known sentence and stop automatically when that sentence ends. */
|
||||
fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) {
|
||||
val sentence = currentTrainingItem()?.sentences?.getOrNull(sentenceIndex)
|
||||
stopAtSentenceEnd = true
|
||||
stopAtSentenceEndIndex = sentenceIndex
|
||||
player.seekTo(max(0L, startMs))
|
||||
if (sentence != null) {
|
||||
if (!ensureSentenceWindow(sentence)) {
|
||||
player.seekTo(max(0L, startMs))
|
||||
}
|
||||
}
|
||||
player.play()
|
||||
refreshSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
|
||||
fun currentTrainingItem(): TrainingMediaItem? {
|
||||
val index = player.currentMediaItemIndex
|
||||
return mediaItems.getOrNull(index)
|
||||
return mediaItems.getOrNull(activeBaseItemIndex)
|
||||
}
|
||||
|
||||
fun currentSentence(): SentenceBoundary? {
|
||||
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 ->
|
||||
position >= sentence.startMs && position < sentence.endMs
|
||||
}
|
||||
@@ -288,7 +333,12 @@ class OralTrainerController internal constructor(
|
||||
if (item.sentences.isEmpty()) {
|
||||
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
|
||||
.asReversed()
|
||||
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
||||
@@ -302,7 +352,13 @@ class OralTrainerController internal constructor(
|
||||
if (item.sentences.isEmpty()) {
|
||||
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 {
|
||||
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
|
||||
} ?: return false
|
||||
@@ -362,12 +418,17 @@ class OralTrainerController internal constructor(
|
||||
|
||||
fun snapshot(): PlaybackSnapshot {
|
||||
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(
|
||||
mediaId = item?.id,
|
||||
positionMs = max(0L, player.currentPosition),
|
||||
positionMs = playbackPositionMs(),
|
||||
durationMs = duration,
|
||||
bufferedPositionMs = max(0L, player.bufferedPosition),
|
||||
bufferedPositionMs = playbackBufferedPositionMs(),
|
||||
isPlaying = player.isPlaying,
|
||||
playbackState = player.playbackState.toOralTrainerState(),
|
||||
playbackSpeed = player.playbackParameters.speed,
|
||||
@@ -380,7 +441,6 @@ class OralTrainerController internal constructor(
|
||||
return
|
||||
}
|
||||
released = true
|
||||
cancelSentenceBoundaryGuard()
|
||||
mainHandler.removeCallbacks(ticker)
|
||||
listeners.clear()
|
||||
player.release()
|
||||
@@ -404,101 +464,42 @@ class OralTrainerController internal constructor(
|
||||
lastSentenceIndex = index
|
||||
listeners.forEach { it.onSentenceChanged(sentence) }
|
||||
if (changed && !config.continuousPlayback && !stopAtSentenceEnd) {
|
||||
refreshSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceSentenceBoundary() {
|
||||
if (!config.sentenceMode || !player.isPlaying) {
|
||||
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()
|
||||
private fun refreshPlaybackWindow() {
|
||||
val item = currentTrainingItem()
|
||||
if (released || !config.sentenceMode) {
|
||||
return
|
||||
}
|
||||
if (!player.isPlaying && !player.playWhenReady) {
|
||||
return
|
||||
}
|
||||
val item = currentTrainingItem() ?: return
|
||||
if (item.sentences.isEmpty()) {
|
||||
restoreFullWindow(playbackPositionMs())
|
||||
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)
|
||||
if (item == null || item.sentences.isEmpty()) {
|
||||
restoreFullWindow(playbackPositionMs())
|
||||
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)
|
||||
if (config.continuousPlayback && !stopAtSentenceEnd) {
|
||||
restoreFullWindow(playbackPositionMs())
|
||||
return
|
||||
}
|
||||
.setLooper(mainHandler.looper)
|
||||
.setPosition(mediaItemIndex, sentence.endMs)
|
||||
.setDeleteAfterDelivery(true)
|
||||
.send()
|
||||
}
|
||||
|
||||
private fun cancelSentenceBoundaryGuard() {
|
||||
boundaryGuardToken++
|
||||
boundaryMessage?.let { message ->
|
||||
runCatching { message.cancel() }
|
||||
val sentence = sentenceForPlaybackWindow(item) ?: return
|
||||
if (!player.isPlaying && !player.playWhenReady && activeSentenceWindow == null) {
|
||||
return
|
||||
}
|
||||
boundaryMessage = null
|
||||
|
||||
ensureSentenceWindow(sentence)
|
||||
refreshSentenceRepeatMode()
|
||||
}
|
||||
|
||||
private fun applySentenceEndAction(sentence: SentenceBoundary) {
|
||||
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.pause()
|
||||
player.seekTo(sentence.startMs)
|
||||
player.seekTo(0L)
|
||||
lastSentenceIndex = sentence.index
|
||||
notifySentenceIfChanged(force = true)
|
||||
notifySnapshot()
|
||||
@@ -507,20 +508,232 @@ class OralTrainerController internal constructor(
|
||||
if (config.continuousPlayback) {
|
||||
return
|
||||
}
|
||||
// Loop the current sentence without leaking into the next one.
|
||||
player.seekTo(sentence.startMs)
|
||||
player.pause()
|
||||
// 当前 MediaItem 已 clipped 到 [startMs, endMs),句首就是窗口内的 0ms。
|
||||
player.seekTo(0L)
|
||||
if (player.playbackState == Player.STATE_ENDED) {
|
||||
player.prepare()
|
||||
}
|
||||
player.play()
|
||||
lastSentenceIndex = sentence.index
|
||||
notifySentenceIfChanged(force = true)
|
||||
refreshSentenceBoundaryGuard()
|
||||
refreshPlaybackWindow()
|
||||
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()
|
||||
.setMediaId(id)
|
||||
.setUri(uri)
|
||||
mimeType?.let(builder::setMimeType)
|
||||
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 ->
|
||||
builder.setSubtitleConfigurations(
|
||||
listOf(
|
||||
|
||||
Reference in New Issue
Block a user