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