fff
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Binary file not shown.
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user