fixed a bug
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 baseItemIndex = 0
|
||||||
private var boundaryGuardToken = 0
|
private var clippedSentence: SentenceBoundary? = null
|
||||||
|
private var updatingPlaybackWindow = false
|
||||||
|
private var unclippedDurationMs = -1L
|
||||||
|
|
||||||
var config: PlayerConfig = initialConfig
|
var config: PlayerConfig = initialConfig
|
||||||
private set
|
private set
|
||||||
@@ -47,10 +48,9 @@ class OralTrainerController internal constructor(
|
|||||||
if (released) {
|
if (released) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
enforceSentenceBoundary()
|
|
||||||
notifySentenceIfChanged()
|
notifySentenceIfChanged()
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
mainHandler.postDelayed(this, 250L)
|
mainHandler.postDelayed(this, IDLE_POLL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,24 +66,33 @@ 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) {
|
||||||
|
onClippedSentenceEnded()
|
||||||
|
}
|
||||||
|
if (playbackState == Player.STATE_READY && unclippedDurationMs <= 0L) {
|
||||||
|
val duration = player.duration
|
||||||
|
if (duration != C.TIME_UNSET && clippedSentence == null) {
|
||||||
|
unclippedDurationMs = duration
|
||||||
|
}
|
||||||
|
}
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
if (isPlaying) {
|
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
} else {
|
|
||||||
cancelSentenceBoundaryGuard()
|
|
||||||
}
|
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
if (updatingPlaybackWindow) {
|
||||||
|
notifySnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (clippedSentence == null) {
|
||||||
|
baseItemIndex = player.currentMediaItemIndex
|
||||||
lastSentenceIndex = null
|
lastSentenceIndex = null
|
||||||
cancelSentenceBoundaryGuard()
|
}
|
||||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +102,6 @@ class OralTrainerController internal constructor(
|
|||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +148,16 @@ 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))
|
baseItemIndex = safeIndex
|
||||||
|
clippedSentence = null
|
||||||
|
unclippedDurationMs = -1L
|
||||||
|
val startMs = max(0L, startPositionMs)
|
||||||
|
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, startMs)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = config.autoPlay
|
player.playWhenReady = config.autoPlay
|
||||||
|
applyLoopMode()
|
||||||
|
lockSentenceAt(startMs)
|
||||||
|
syncPlaybackWindow(startMs)
|
||||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
@@ -166,25 +181,26 @@ class OralTrainerController internal constructor(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
mediaItems[index] = updatedItem
|
mediaItems[index] = updatedItem
|
||||||
if (index == player.currentMediaItemIndex) {
|
if (index == baseItemIndex) {
|
||||||
listeners.forEach { it.onMediaChanged(updatedItem) }
|
listeners.forEach { it.onMediaChanged(updatedItem) }
|
||||||
|
lockSentenceAt(playbackPositionMs())
|
||||||
|
syncPlaybackWindow()
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
|
syncPlaybackWindow()
|
||||||
player.play()
|
player.play()
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = null
|
stopAtSentenceEndIndex = null
|
||||||
cancelSentenceBoundaryGuard()
|
|
||||||
player.pause()
|
player.pause()
|
||||||
|
applySentenceRepeatMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
@@ -198,19 +214,22 @@ 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()
|
lockSentenceAt(safePositionMs)
|
||||||
|
syncPlaybackWindow(safePositionMs)
|
||||||
|
seekInCurrentWindow(safePositionMs)
|
||||||
|
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 +241,11 @@ class OralTrainerController internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun previousItem() {
|
fun previousItem() {
|
||||||
player.seekToPreviousMediaItem()
|
switchBaseItem(baseItemIndex - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nextItem() {
|
fun nextItem() {
|
||||||
player.seekToNextMediaItem()
|
switchBaseItem(baseItemIndex + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlaybackSpeed(speed: Float) {
|
fun setPlaybackSpeed(speed: Float) {
|
||||||
@@ -240,20 +259,21 @@ class OralTrainerController internal constructor(
|
|||||||
|
|
||||||
fun setLoopMode(loopMode: LoopMode) {
|
fun setLoopMode(loopMode: LoopMode) {
|
||||||
this.loopMode = loopMode
|
this.loopMode = loopMode
|
||||||
player.repeatMode = when (loopMode) {
|
applySentenceRepeatMode()
|
||||||
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)
|
||||||
|
syncPlaybackWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setContinuousPlayback(enabled: Boolean) {
|
fun setContinuousPlayback(enabled: Boolean) {
|
||||||
config = config.copy(continuousPlayback = enabled)
|
config = config.copy(continuousPlayback = enabled)
|
||||||
refreshSentenceBoundaryGuard()
|
if (!enabled) {
|
||||||
|
lockSentenceAt(playbackPositionMs())
|
||||||
|
}
|
||||||
|
syncPlaybackWindow()
|
||||||
|
notifySentenceIfChanged(force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playCurrentSentenceAndStop() {
|
fun playCurrentSentenceAndStop() {
|
||||||
@@ -265,22 +285,37 @@ class OralTrainerController internal constructor(
|
|||||||
fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) {
|
fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) {
|
||||||
stopAtSentenceEnd = true
|
stopAtSentenceEnd = true
|
||||||
stopAtSentenceEndIndex = sentenceIndex
|
stopAtSentenceEndIndex = sentenceIndex
|
||||||
|
lastSentenceIndex = sentenceIndex
|
||||||
|
val item = currentTrainingItem()
|
||||||
|
val sentence = item?.sentences?.getOrNull(sentenceIndex)
|
||||||
|
if (item != null && sentence != null) {
|
||||||
|
installSentenceClip(item, sentence, 0L)
|
||||||
|
} else {
|
||||||
player.seekTo(max(0L, startMs))
|
player.seekTo(max(0L, startMs))
|
||||||
|
}
|
||||||
player.play()
|
player.play()
|
||||||
refreshSentenceBoundaryGuard()
|
notifySentenceIfChanged(force = true)
|
||||||
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentTrainingItem(): TrainingMediaItem? {
|
fun currentTrainingItem(): TrainingMediaItem? {
|
||||||
val index = player.currentMediaItemIndex
|
return mediaItems.getOrNull(baseItemIndex)
|
||||||
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 (item.sentences.isEmpty()) {
|
||||||
return item.sentences.lastOrNull { sentence ->
|
return null
|
||||||
position >= sentence.startMs && position < sentence.endMs
|
|
||||||
}
|
}
|
||||||
|
clippedSentence?.let { window ->
|
||||||
|
return item.sentences.getOrNull(window.index)
|
||||||
|
}
|
||||||
|
if (shouldBoundCurrentSentence()) {
|
||||||
|
lastSentenceIndex?.let { index ->
|
||||||
|
item.sentences.getOrNull(index)?.let { return it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sentenceAt(item, playbackPositionMs())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekToPreviousSentence(): Boolean {
|
fun seekToPreviousSentence(): Boolean {
|
||||||
@@ -288,11 +323,16 @@ class OralTrainerController internal constructor(
|
|||||||
if (item.sentences.isEmpty()) {
|
if (item.sentences.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val position = player.currentPosition
|
val target = if (shouldBoundCurrentSentence()) {
|
||||||
val target = item.sentences
|
val index = anchoredSentenceIndex(item) ?: 0
|
||||||
|
item.sentences.getOrNull((index - 1).coerceAtLeast(0)) ?: item.sentences.first()
|
||||||
|
} else {
|
||||||
|
val position = playbackPositionMs()
|
||||||
|
item.sentences
|
||||||
.asReversed()
|
.asReversed()
|
||||||
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
||||||
?: item.sentences.first()
|
?: item.sentences.first()
|
||||||
|
}
|
||||||
seekTo(target.startMs)
|
seekTo(target.startMs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -302,10 +342,15 @@ class OralTrainerController internal constructor(
|
|||||||
if (item.sentences.isEmpty()) {
|
if (item.sentences.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val position = player.currentPosition
|
val target = if (shouldBoundCurrentSentence()) {
|
||||||
val target = item.sentences.firstOrNull {
|
val index = anchoredSentenceIndex(item) ?: return false
|
||||||
|
item.sentences.getOrNull(index + 1) ?: return false
|
||||||
|
} else {
|
||||||
|
val position = playbackPositionMs()
|
||||||
|
item.sentences.firstOrNull {
|
||||||
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
|
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
|
||||||
} ?: return false
|
} ?: return false
|
||||||
|
}
|
||||||
seekTo(target.startMs)
|
seekTo(target.startMs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -362,12 +407,16 @@ 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 duration = when {
|
||||||
|
unclippedDurationMs > 0L -> unclippedDurationMs
|
||||||
|
clippedSentence != null -> clippedSentence?.endMs ?: -1L
|
||||||
|
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 +429,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()
|
||||||
@@ -400,127 +448,224 @@ class OralTrainerController internal constructor(
|
|||||||
if (!force && index == lastSentenceIndex) {
|
if (!force && index == lastSentenceIndex) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val changed = index != lastSentenceIndex
|
|
||||||
lastSentenceIndex = index
|
lastSentenceIndex = index
|
||||||
listeners.forEach { it.onSentenceChanged(sentence) }
|
listeners.forEach { it.onSentenceChanged(sentence) }
|
||||||
if (changed && !config.continuousPlayback && !stopAtSentenceEnd) {
|
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enforceSentenceBoundary() {
|
private fun syncPlaybackWindow(positionMs: Long = playbackPositionMs()) {
|
||||||
if (!config.sentenceMode || !player.isPlaying) {
|
if (released || updatingPlaybackWindow) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val item = currentTrainingItem() ?: return
|
val item = currentTrainingItem()
|
||||||
if (item.sentences.isEmpty()) {
|
val target = sentenceForWindow(item)
|
||||||
|
if (target == null || item == null) {
|
||||||
|
restoreUnclipped(positionMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val position = player.currentPosition
|
val current = clippedSentence
|
||||||
if (stopAtSentenceEnd) {
|
if (current != null &&
|
||||||
val index = stopAtSentenceEndIndex ?: return
|
current.index == target.index &&
|
||||||
val sentence = item.sentences.getOrNull(index) ?: return
|
current.startMs == target.startMs &&
|
||||||
if (position >= sentence.endMs) {
|
current.endMs == target.endMs
|
||||||
applySentenceEndAction(sentence)
|
) {
|
||||||
}
|
applySentenceRepeatMode()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (config.continuousPlayback) {
|
val relativeMs = (positionMs - target.startMs).coerceAtLeast(0L)
|
||||||
return
|
installSentenceClip(item, target, relativeMs)
|
||||||
}
|
|
||||||
val anchorIndex = lastSentenceIndex ?: currentSentence()?.index ?: return
|
|
||||||
val anchor = item.sentences.getOrNull(anchorIndex) ?: return
|
|
||||||
if (position >= anchor.endMs) {
|
|
||||||
applySentenceEndAction(anchor)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshSentenceBoundaryGuard() {
|
private fun sentenceForWindow(item: TrainingMediaItem?): SentenceBoundary? {
|
||||||
cancelSentenceBoundaryGuard()
|
if (item == null || !config.sentenceMode || item.sentences.isEmpty()) {
|
||||||
if (released || !config.sentenceMode) {
|
return null
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!player.isPlaying && !player.playWhenReady) {
|
return when {
|
||||||
return
|
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null)
|
||||||
}
|
|
||||||
val item = currentTrainingItem() ?: return
|
|
||||||
if (item.sentences.isEmpty()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sentence = when {
|
|
||||||
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return)
|
|
||||||
config.continuousPlayback -> null
|
config.continuousPlayback -> null
|
||||||
else -> {
|
else -> {
|
||||||
val index = lastSentenceIndex ?: currentSentence()?.index
|
val index = lastSentenceIndex ?: sentenceAt(item, playbackPositionMs())?.index
|
||||||
item.sentences.getOrNull(index ?: return)
|
item.sentences.getOrNull(index ?: return null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} ?: return
|
|
||||||
|
|
||||||
val mediaItemIndex = player.currentMediaItemIndex
|
private fun installSentenceClip(
|
||||||
if (player.currentPosition >= sentence.endMs) {
|
item: TrainingMediaItem,
|
||||||
applySentenceEndAction(sentence)
|
sentence: SentenceBoundary,
|
||||||
|
relativePositionMs: Long,
|
||||||
|
) {
|
||||||
|
if (released) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
rememberUnclippedDuration()
|
||||||
val token = ++boundaryGuardToken
|
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
|
||||||
boundaryMessage = player.createMessage { _, _ ->
|
val startPositionMs = relativePositionMs.coerceIn(0L, lengthMs)
|
||||||
if (token != boundaryGuardToken || released) {
|
val playWhenReady = player.playWhenReady
|
||||||
return@createMessage
|
updatingPlaybackWindow = true
|
||||||
}
|
clippedSentence = sentence
|
||||||
val active = currentTrainingItem() ?: return@createMessage
|
lastSentenceIndex = sentence.index
|
||||||
val latest = when {
|
player.setMediaItem(item.toMedia3Item(sentence.startMs, sentence.endMs), startPositionMs)
|
||||||
stopAtSentenceEnd -> active.sentences.getOrNull(stopAtSentenceEndIndex ?: return@createMessage)
|
player.prepare()
|
||||||
else -> active.sentences.getOrNull(sentence.index)
|
player.playWhenReady = playWhenReady
|
||||||
} ?: return@createMessage
|
applySentenceRepeatMode()
|
||||||
if (player.currentMediaItemIndex != mediaItemIndex) {
|
updatingPlaybackWindow = false
|
||||||
return@createMessage
|
|
||||||
}
|
|
||||||
applySentenceEndAction(latest)
|
|
||||||
}
|
|
||||||
.setLooper(mainHandler.looper)
|
|
||||||
.setPosition(mediaItemIndex, sentence.endMs)
|
|
||||||
.setDeleteAfterDelivery(true)
|
|
||||||
.send()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelSentenceBoundaryGuard() {
|
private fun restoreUnclipped(positionMs: Long) {
|
||||||
boundaryGuardToken++
|
if (released) {
|
||||||
boundaryMessage?.let { message ->
|
return
|
||||||
runCatching { message.cancel() }
|
|
||||||
}
|
}
|
||||||
boundaryMessage = null
|
if (clippedSentence == null && player.mediaItemCount == mediaItems.size) {
|
||||||
|
applyLoopMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val playWhenReady = player.playWhenReady
|
||||||
|
updatingPlaybackWindow = true
|
||||||
|
clippedSentence = null
|
||||||
|
player.setMediaItems(
|
||||||
|
mediaItems.map { it.toMedia3Item() },
|
||||||
|
baseItemIndex.coerceIn(0, mediaItems.lastIndex),
|
||||||
|
max(0L, positionMs),
|
||||||
|
)
|
||||||
|
player.prepare()
|
||||||
|
player.playWhenReady = playWhenReady
|
||||||
|
applyLoopMode()
|
||||||
|
updatingPlaybackWindow = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applySentenceEndAction(sentence: SentenceBoundary) {
|
private fun seekInCurrentWindow(positionMs: Long) {
|
||||||
|
val sentence = clippedSentence
|
||||||
|
if (sentence == null) {
|
||||||
|
player.seekTo(max(0L, positionMs))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
|
||||||
|
player.seekTo((positionMs - sentence.startMs).coerceIn(0L, lengthMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onClippedSentenceEnded() {
|
||||||
|
val sentence = clippedSentence ?: return
|
||||||
|
player.seekTo(0L)
|
||||||
|
if (player.playbackState == Player.STATE_ENDED) {
|
||||||
|
player.prepare()
|
||||||
|
}
|
||||||
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)
|
applySentenceRepeatMode()
|
||||||
|
} else if (!config.continuousPlayback) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
lastSentenceIndex = sentence.index
|
lastSentenceIndex = sentence.index
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
return
|
|
||||||
}
|
|
||||||
if (config.continuousPlayback) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Loop the current sentence without leaking into the next one.
|
|
||||||
player.seekTo(sentence.startMs)
|
|
||||||
lastSentenceIndex = sentence.index
|
|
||||||
notifySentenceIfChanged(force = true)
|
|
||||||
refreshSentenceBoundaryGuard()
|
|
||||||
notifySnapshot()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TrainingMediaItem.toMedia3Item(): MediaItem {
|
private fun switchBaseItem(targetIndex: Int) {
|
||||||
|
if (targetIndex !in mediaItems.indices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseItemIndex = targetIndex
|
||||||
|
lastSentenceIndex = null
|
||||||
|
stopAtSentenceEnd = false
|
||||||
|
stopAtSentenceEndIndex = null
|
||||||
|
clippedSentence = null
|
||||||
|
unclippedDurationMs = -1L
|
||||||
|
val item = mediaItems[targetIndex]
|
||||||
|
val firstSentence = item.sentences.firstOrNull()
|
||||||
|
if (shouldBoundCurrentSentence() && firstSentence != null) {
|
||||||
|
lastSentenceIndex = firstSentence.index
|
||||||
|
installSentenceClip(item, firstSentence, 0L)
|
||||||
|
} else {
|
||||||
|
player.setMediaItem(item.toMedia3Item(), 0L)
|
||||||
|
player.prepare()
|
||||||
|
applyLoopMode()
|
||||||
|
}
|
||||||
|
listeners.forEach { it.onMediaChanged(item) }
|
||||||
|
notifySentenceIfChanged(force = true)
|
||||||
|
notifySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playbackPositionMs(): Long {
|
||||||
|
val sentence = clippedSentence ?: return max(0L, player.currentPosition)
|
||||||
|
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
|
||||||
|
return sentence.startMs + player.currentPosition.coerceIn(0L, lengthMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playbackBufferedPositionMs(): Long {
|
||||||
|
val sentence = clippedSentence ?: return max(0L, player.bufferedPosition)
|
||||||
|
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
|
||||||
|
return sentence.startMs + player.bufferedPosition.coerceIn(0L, lengthMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldBoundCurrentSentence(): Boolean {
|
||||||
|
return config.sentenceMode && (!config.continuousPlayback || stopAtSentenceEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lockSentenceAt(positionMs: Long) {
|
||||||
|
if (!shouldBoundCurrentSentence()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val item = currentTrainingItem() ?: return
|
||||||
|
lastSentenceIndex = sentenceAt(item, positionMs)?.index
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anchoredSentenceIndex(item: TrainingMediaItem): Int? {
|
||||||
|
return lastSentenceIndex?.takeIf { it in item.sentences.indices }
|
||||||
|
?: clippedSentence?.index
|
||||||
|
?: sentenceAt(item, playbackPositionMs())?.index
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sentenceAt(item: TrainingMediaItem, positionMs: Long): SentenceBoundary? {
|
||||||
|
return item.sentences.lastOrNull { positionMs >= it.startMs && positionMs < it.endMs }
|
||||||
|
?: item.sentences.lastOrNull { it.startMs <= positionMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rememberUnclippedDuration() {
|
||||||
|
if (unclippedDurationMs > 0L || clippedSentence != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val duration = player.duration
|
||||||
|
if (duration != C.TIME_UNSET) {
|
||||||
|
unclippedDurationMs = duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLoopMode() {
|
||||||
|
player.repeatMode = when (loopMode) {
|
||||||
|
LoopMode.OFF -> Player.REPEAT_MODE_OFF
|
||||||
|
LoopMode.ONE -> Player.REPEAT_MODE_ONE
|
||||||
|
LoopMode.ALL -> Player.REPEAT_MODE_ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySentenceRepeatMode() {
|
||||||
|
if (clippedSentence == null) {
|
||||||
|
applyLoopMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
player.repeatMode = Player.REPEAT_MODE_OFF
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -552,5 +697,6 @@ class OralTrainerController internal constructor(
|
|||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user