fixed a bug

This commit is contained in:
2026-09-12 20:27:42 +08:00
parent 93232a64fa
commit 96d0ff5318
2 changed files with 282 additions and 136 deletions

View File

@@ -11,7 +11,6 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.max import kotlin.math.max
@@ -31,8 +30,10 @@ class OralTrainerController internal constructor(
private var lastSentenceIndex: Int? = null private var lastSentenceIndex: Int? = null
private var stopAtSentenceEnd = false private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int? = null private var stopAtSentenceEndIndex: Int? = null
private var boundaryMessage: PlayerMessage? = null private var 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
} }
} }