diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt index 57d9fb5..fdaa409 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt @@ -11,7 +11,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.PlayerMessage import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import java.util.concurrent.CopyOnWriteArraySet import kotlin.math.max @@ -31,8 +30,10 @@ class OralTrainerController internal constructor( private var lastSentenceIndex: Int? = null private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? = null - private var boundaryMessage: PlayerMessage? = null - private var boundaryGuardToken = 0 + private var activeBaseItemIndex: Int = -1 + private var activeSentenceWindow: SentenceWindow? = null + private var sentenceWindows: List = emptyList() + private var sentencePlaylistBaseIndex: Int = -1 var config: PlayerConfig = initialConfig private set @@ -47,13 +48,20 @@ class OralTrainerController internal constructor( if (released) { return } - enforceSentenceBoundary() notifySentenceIfChanged() notifySnapshot() mainHandler.postDelayed(this, 250L) } } + private data class SentenceWindow( + val baseItemIndex: Int, + val mediaId: String, + val sentenceIndex: Int, + val startMs: Long, + val endMs: Long, + ) + init { val mediaSourceFactory = DefaultMediaSourceFactory( StreamingCache.dataSourceFactory(appContext, sdkConfig) @@ -66,24 +74,34 @@ class OralTrainerController internal constructor( exoPlayer.playWhenReady = initialConfig.autoPlay exoPlayer.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + applySentenceEndForAnchor() + } + if (playbackState == Player.STATE_READY) { + refreshPlaybackWindow() + } + if (playbackState == Player.STATE_READY || + playbackState == Player.STATE_ENDED + ) { + refreshSentenceRepeatMode() + } notifySnapshot() } override fun onIsPlayingChanged(isPlaying: Boolean) { if (isPlaying) { - refreshSentenceBoundaryGuard() + refreshPlaybackWindow() } else { - cancelSentenceBoundaryGuard() + refreshPlaybackWindow() } notifySnapshot() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - lastSentenceIndex = null - cancelSentenceBoundaryGuard() + activeSentenceWindow = currentSentenceWindow() + lastSentenceIndex = activeSentenceWindow?.sentenceIndex listeners.forEach { it.onMediaChanged(currentTrainingItem()) } notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() notifySnapshot() } @@ -93,7 +111,9 @@ class OralTrainerController internal constructor( reason: Int, ) { notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() + activeSentenceWindow = currentSentenceWindow() + lastSentenceIndex = activeSentenceWindow?.sentenceIndex + refreshPlaybackWindow() notifySnapshot() } @@ -140,7 +160,11 @@ class OralTrainerController internal constructor( lastSentenceIndex = null stopAtSentenceEnd = false stopAtSentenceEndIndex = null - player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs)) + activeBaseItemIndex = safeIndex + activeSentenceWindow = null + sentenceWindows = emptyList() + sentencePlaylistBaseIndex = -1 + player.setMediaItem(items[safeIndex].toMedia3Item(), max(0L, startPositionMs)) player.prepare() player.playWhenReady = config.autoPlay listeners.forEach { it.onMediaChanged(currentTrainingItem()) } @@ -166,10 +190,14 @@ class OralTrainerController internal constructor( return true } mediaItems[index] = updatedItem - if (index == player.currentMediaItemIndex) { + if (index == activeBaseItemIndex) { listeners.forEach { it.onMediaChanged(updatedItem) } + if (config.sentenceMode && !config.continuousPlayback && sentences.isNotEmpty()) { + installSentencePlaylist(index, activeSentenceWindow?.sentenceIndex ?: 0) + } else { + refreshPlaybackWindow() + } notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() notifySnapshot() } return true @@ -177,13 +205,12 @@ class OralTrainerController internal constructor( fun play() { player.play() - refreshSentenceBoundaryGuard() + refreshPlaybackWindow() } fun pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - cancelSentenceBoundaryGuard() player.pause() } @@ -198,19 +225,31 @@ class OralTrainerController internal constructor( fun stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - cancelSentenceBoundaryGuard() player.stop() } fun seekTo(positionMs: Long) { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - player.seekTo(max(0L, positionMs)) - refreshSentenceBoundaryGuard() + val safePositionMs = max(0L, positionMs) + val anchor = sentenceAnchorAt(safePositionMs) + if (config.sentenceMode && !config.continuousPlayback && anchor != null) { + if (ensureSentenceWindow(anchor)) { + seekInSentenceWindow(anchor, safePositionMs) + } else { + player.seekTo(safePositionMs) + } + } else { + restoreFullWindow(safePositionMs) + player.seekTo(safePositionMs) + } + refreshPlaybackWindow() + notifySentenceIfChanged(force = true) + notifySnapshot() } fun seekRelative(deltaMs: Long) { - seekTo(player.currentPosition + deltaMs) + seekTo(playbackPositionMs() + deltaMs) } fun rewind(stepMs: Long = config.defaultSeekStepMs) { @@ -222,11 +261,11 @@ class OralTrainerController internal constructor( } fun previousItem() { - player.seekToPreviousMediaItem() + switchBaseItem(activeBaseItemIndex - 1) } fun nextItem() { - player.seekToNextMediaItem() + switchBaseItem(activeBaseItemIndex + 1) } fun setPlaybackSpeed(speed: Float) { @@ -240,44 +279,50 @@ class OralTrainerController internal constructor( fun setLoopMode(loopMode: LoopMode) { this.loopMode = loopMode - player.repeatMode = when (loopMode) { - LoopMode.OFF -> Player.REPEAT_MODE_OFF - LoopMode.ONE -> Player.REPEAT_MODE_ONE - LoopMode.ALL -> Player.REPEAT_MODE_ALL - } + refreshSentenceRepeatMode() } fun setSentenceMode(enabled: Boolean) { config = config.copy(sentenceMode = enabled) + refreshPlaybackWindow() } fun setContinuousPlayback(enabled: Boolean) { config = config.copy(continuousPlayback = enabled) - refreshSentenceBoundaryGuard() + refreshPlaybackWindow() } fun playCurrentSentenceAndStop() { - val sentence = currentSentence() ?: return + val sentence = activeSentenceWindow?.let { window -> + currentTrainingItem()?.sentences?.getOrNull(window.sentenceIndex) + } ?: currentSentence() ?: return playSentenceAndStop(sentence.startMs, sentence.index) } /** Seek to a known sentence and stop automatically when that sentence ends. */ fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) { + val sentence = currentTrainingItem()?.sentences?.getOrNull(sentenceIndex) stopAtSentenceEnd = true stopAtSentenceEndIndex = sentenceIndex - player.seekTo(max(0L, startMs)) + if (sentence != null) { + if (!ensureSentenceWindow(sentence)) { + player.seekTo(max(0L, startMs)) + } + } player.play() - refreshSentenceBoundaryGuard() + refreshPlaybackWindow() } fun currentTrainingItem(): TrainingMediaItem? { - val index = player.currentMediaItemIndex - return mediaItems.getOrNull(index) + return mediaItems.getOrNull(activeBaseItemIndex) } fun currentSentence(): SentenceBoundary? { val item = currentTrainingItem() ?: return null - val position = player.currentPosition + if (isSentencePlaylistActive()) { + return item.sentences.getOrNull(player.currentMediaItemIndex) + } + val position = playbackPositionMs() return item.sentences.lastOrNull { sentence -> position >= sentence.startMs && position < sentence.endMs } @@ -288,7 +333,12 @@ class OralTrainerController internal constructor( if (item.sentences.isEmpty()) { return false } - val position = player.currentPosition + if (isSentencePlaylistActive()) { + val index = (player.currentMediaItemIndex - 1).coerceAtLeast(0) + seekTo(item.sentences[index].startMs) + return true + } + val position = playbackPositionMs() val target = item.sentences .asReversed() .firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS } @@ -302,7 +352,13 @@ class OralTrainerController internal constructor( if (item.sentences.isEmpty()) { return false } - val position = player.currentPosition + if (isSentencePlaylistActive()) { + val index = (player.currentMediaItemIndex + 1) + .takeIf { it < item.sentences.size } ?: return false + seekTo(item.sentences[index].startMs) + return true + } + val position = playbackPositionMs() val target = item.sentences.firstOrNull { it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS } ?: return false @@ -362,12 +418,17 @@ class OralTrainerController internal constructor( fun snapshot(): PlaybackSnapshot { val item = currentTrainingItem() - val duration = player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L + val window = activeSentenceWindow + val duration = if (window != null) { + window.endMs + } else { + player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L + } return PlaybackSnapshot( mediaId = item?.id, - positionMs = max(0L, player.currentPosition), + positionMs = playbackPositionMs(), durationMs = duration, - bufferedPositionMs = max(0L, player.bufferedPosition), + bufferedPositionMs = playbackBufferedPositionMs(), isPlaying = player.isPlaying, playbackState = player.playbackState.toOralTrainerState(), playbackSpeed = player.playbackParameters.speed, @@ -380,7 +441,6 @@ class OralTrainerController internal constructor( return } released = true - cancelSentenceBoundaryGuard() mainHandler.removeCallbacks(ticker) listeners.clear() player.release() @@ -404,101 +464,42 @@ class OralTrainerController internal constructor( lastSentenceIndex = index listeners.forEach { it.onSentenceChanged(sentence) } if (changed && !config.continuousPlayback && !stopAtSentenceEnd) { - refreshSentenceBoundaryGuard() + refreshPlaybackWindow() } } - private fun enforceSentenceBoundary() { - if (!config.sentenceMode || !player.isPlaying) { - return - } - val item = currentTrainingItem() ?: return - if (item.sentences.isEmpty()) { - return - } - val position = player.currentPosition - if (stopAtSentenceEnd) { - val index = stopAtSentenceEndIndex ?: return - val sentence = item.sentences.getOrNull(index) ?: return - if (position >= sentence.endMs) { - applySentenceEndAction(sentence) - } - return - } - if (config.continuousPlayback) { - return - } - val anchorIndex = lastSentenceIndex ?: currentSentence()?.index ?: return - val anchor = item.sentences.getOrNull(anchorIndex) ?: return - if (position >= anchor.endMs) { - applySentenceEndAction(anchor) - } - } - - private fun refreshSentenceBoundaryGuard() { - cancelSentenceBoundaryGuard() + private fun refreshPlaybackWindow() { + val item = currentTrainingItem() if (released || !config.sentenceMode) { - return - } - if (!player.isPlaying && !player.playWhenReady) { - return - } - val item = currentTrainingItem() ?: return - if (item.sentences.isEmpty()) { + restoreFullWindow(playbackPositionMs()) return } - val sentence = when { - stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return) - config.continuousPlayback -> null - else -> { - val index = lastSentenceIndex ?: currentSentence()?.index - item.sentences.getOrNull(index ?: return) - } - } ?: return - - val mediaItemIndex = player.currentMediaItemIndex - if (player.currentPosition >= sentence.endMs) { - applySentenceEndAction(sentence) + if (item == null || item.sentences.isEmpty()) { + restoreFullWindow(playbackPositionMs()) return } - val token = ++boundaryGuardToken - boundaryMessage = player.createMessage { _, _ -> - if (token != boundaryGuardToken || released) { - return@createMessage - } - val active = currentTrainingItem() ?: return@createMessage - val latest = when { - stopAtSentenceEnd -> active.sentences.getOrNull(stopAtSentenceEndIndex ?: return@createMessage) - else -> active.sentences.getOrNull(sentence.index) - } ?: return@createMessage - if (player.currentMediaItemIndex != mediaItemIndex) { - return@createMessage - } - applySentenceEndAction(latest) + if (config.continuousPlayback && !stopAtSentenceEnd) { + restoreFullWindow(playbackPositionMs()) + return } - .setLooper(mainHandler.looper) - .setPosition(mediaItemIndex, sentence.endMs) - .setDeleteAfterDelivery(true) - .send() - } - private fun cancelSentenceBoundaryGuard() { - boundaryGuardToken++ - boundaryMessage?.let { message -> - runCatching { message.cancel() } + val sentence = sentenceForPlaybackWindow(item) ?: return + if (!player.isPlaying && !player.playWhenReady && activeSentenceWindow == null) { + return } - boundaryMessage = null + + ensureSentenceWindow(sentence) + refreshSentenceRepeatMode() } private fun applySentenceEndAction(sentence: SentenceBoundary) { if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - cancelSentenceBoundaryGuard() player.pause() - player.seekTo(sentence.startMs) + player.seekTo(0L) lastSentenceIndex = sentence.index notifySentenceIfChanged(force = true) notifySnapshot() @@ -507,20 +508,232 @@ class OralTrainerController internal constructor( if (config.continuousPlayback) { return } - // Loop the current sentence without leaking into the next one. - player.seekTo(sentence.startMs) + player.pause() + // 当前 MediaItem 已 clipped 到 [startMs, endMs),句首就是窗口内的 0ms。 + player.seekTo(0L) + if (player.playbackState == Player.STATE_ENDED) { + player.prepare() + } + player.play() lastSentenceIndex = sentence.index notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() + refreshPlaybackWindow() notifySnapshot() } - private fun TrainingMediaItem.toMedia3Item(): MediaItem { + private fun applySentenceEndForAnchor() { + if (!config.sentenceMode || config.continuousPlayback) { + return + } + val item = currentTrainingItem() + val index = currentSentenceIndex() ?: lastSentenceIndex ?: return + val sentence = item?.sentences?.getOrNull(index) ?: return + applySentenceEndAction(sentence) + } + + private fun sentenceAnchorAt(positionMs: Long): SentenceBoundary? { + val item = currentTrainingItem() ?: return null + return item.sentences.lastOrNull { positionMs >= it.startMs && positionMs < it.endMs } + ?: item.sentences.lastOrNull { it.startMs <= positionMs } + } + + private fun sentenceForPlaybackWindow(item: TrainingMediaItem?): SentenceBoundary? { + if (item == null || item.sentences.isEmpty()) { + return null + } + return when { + !config.sentenceMode -> null + stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null) + config.continuousPlayback -> null + else -> { + val index = currentSentenceIndex() + ?: lastSentenceIndex + ?: currentSentence()?.index + ?: sentenceAnchorAt(playbackPositionMs())?.index + item.sentences.getOrNull(index ?: return null) + } + } + } + + private fun playbackPositionMs(): Long { + val window = activeSentenceWindow ?: return max(0L, player.currentPosition) + val lengthMs = (window.endMs - window.startMs).coerceAtLeast(0L) + val clippedPositionMs = player.currentPosition.coerceIn(0L, lengthMs) + return window.startMs + clippedPositionMs + } + + private fun playbackBufferedPositionMs(): Long { + val window = activeSentenceWindow ?: return max(0L, player.bufferedPosition) + val lengthMs = (window.endMs - window.startMs).coerceAtLeast(0L) + val clippedBufferedMs = player.bufferedPosition.coerceIn(0L, lengthMs) + return window.startMs + clippedBufferedMs + } + + private fun ensureSentenceWindow(target: SentenceBoundary): Boolean { + if (released) { + return false + } + if (sentencePlaylistBaseIndex != activeBaseItemIndex && + player.playbackState in listOf(Player.STATE_READY, Player.STATE_ENDED) + ) { + installSentencePlaylist(activeBaseItemIndex, target.index) + return true + } + if (!isSentencePlaylistActive()) { + return false + } + + val desired = sentenceWindows.getOrNull(target.index) ?: return false + if (player.currentMediaItemIndex != target.index) { + player.seekTo(target.index, 0L) + } + activeSentenceWindow = desired + return true + } + + private fun installSentencePlaylist(baseIndex: Int, startIndex: Int) { + if (released) return + val baseItem = mediaItems.getOrNull(baseIndex) ?: return + val sentences = baseItem.sentences + if (sentences.isEmpty()) return + + sentenceWindows = sentences.mapIndexed { index, sentence -> + SentenceWindow( + baseItemIndex = baseIndex, + mediaId = baseItem.id, + sentenceIndex = index, + startMs = sentence.startMs, + endMs = sentence.endMs, + ) + } + sentencePlaylistBaseIndex = baseIndex + activeBaseItemIndex = baseIndex + val safeStartIndex = startIndex.coerceIn(0, sentenceWindows.lastIndex) + activeSentenceWindow = sentenceWindows[safeStartIndex] + lastSentenceIndex = safeStartIndex + + val playWhenReady = player.playWhenReady + player.setMediaItems( + sentenceWindows.map { window -> + baseItem.toMedia3Item(window.startMs, window.endMs) + }, + safeStartIndex, + 0L, + ) + player.prepare() + player.playWhenReady = playWhenReady + refreshSentenceRepeatMode() + } + + private fun currentSentenceWindow(): SentenceWindow? { + if (!isSentencePlaylistActive()) return null + return sentenceWindows.getOrNull(player.currentMediaItemIndex) + } + + private fun currentSentenceIndex(): Int? { + if (!isSentencePlaylistActive()) return null + return player.currentMediaItemIndex.takeIf { it in sentenceWindows.indices } + } + + private fun isSentencePlaylistActive(): Boolean { + return sentencePlaylistBaseIndex == activeBaseItemIndex && + sentenceWindows.isNotEmpty() && + player.currentMediaItemIndex in sentenceWindows.indices + } + + private fun refreshSentenceRepeatMode() { + if (!isSentencePlaylistActive()) { + player.repeatMode = when (loopMode) { + LoopMode.OFF -> Player.REPEAT_MODE_OFF + LoopMode.ONE -> Player.REPEAT_MODE_ONE + LoopMode.ALL -> Player.REPEAT_MODE_ALL + } + return + } + player.repeatMode = when { + stopAtSentenceEnd || !config.continuousPlayback -> Player.REPEAT_MODE_ONE + loopMode == LoopMode.OFF -> Player.REPEAT_MODE_OFF + else -> Player.REPEAT_MODE_ALL + } + } + + private fun seekInSentenceWindow(target: SentenceBoundary, originalPositionMs: Long) { + val relativePositionMs = (originalPositionMs - target.startMs).coerceIn( + 0L, + (target.endMs - target.startMs).coerceAtLeast(0L), + ) + player.seekTo(relativePositionMs) + } + + private fun restoreFullWindow(positionMs: Long) { + val baseIndex = activeBaseItemIndex + val baseItem = mediaItems.getOrNull(baseIndex) ?: return + if (sentencePlaylistBaseIndex == -1 && + activeSentenceWindow == null && + baseItem.id == player.currentMediaItem?.mediaId + ) { + return + } + + val playWhenReady = player.playWhenReady + activeSentenceWindow = null + sentenceWindows = emptyList() + sentencePlaylistBaseIndex = -1 + player.setMediaItem(baseItem.toMedia3Item(), max(0L, positionMs)) + player.prepare() + player.playWhenReady = playWhenReady + player.repeatMode = when (loopMode) { + LoopMode.OFF -> Player.REPEAT_MODE_OFF + LoopMode.ONE -> Player.REPEAT_MODE_ONE + LoopMode.ALL -> Player.REPEAT_MODE_ALL + } + } + + private fun switchBaseItem(targetIndex: Int) { + if (targetIndex !in mediaItems.indices) { + return + } + activeBaseItemIndex = targetIndex + activeSentenceWindow = null + sentenceWindows = emptyList() + sentencePlaylistBaseIndex = -1 + lastSentenceIndex = null + stopAtSentenceEnd = false + stopAtSentenceEndIndex = null + if (config.sentenceMode && mediaItems[targetIndex].sentences.isNotEmpty()) { + installSentencePlaylist(targetIndex, 0) + } else { + player.setMediaItem(mediaItems[targetIndex].toMedia3Item(), 0L) + player.prepare() + player.repeatMode = when (loopMode) { + LoopMode.OFF -> Player.REPEAT_MODE_OFF + LoopMode.ONE -> Player.REPEAT_MODE_ONE + LoopMode.ALL -> Player.REPEAT_MODE_ALL + } + } + listeners.forEach { it.onMediaChanged(currentTrainingItem()) } + notifySentenceIfChanged(force = true) + notifySnapshot() + } + + private fun TrainingMediaItem.toMedia3Item( + clippingStartPositionMs: Long = 0L, + clippingEndPositionMs: Long? = null, + ): MediaItem { val builder = MediaItem.Builder() .setMediaId(id) .setUri(uri) mimeType?.let(builder::setMimeType) customCacheKey?.let(builder::setCustomCacheKey) + if (clippingEndPositionMs != null) { + builder.setClippingConfiguration( + MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(max(0L, clippingStartPositionMs)) + .setEndPositionMs(max(0L, clippingEndPositionMs)) + .setStartsAtKeyFrame(false) + .build() + ) + } subtitleUri?.let { uri -> builder.setSubtitleConfigurations( listOf( diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index 12642f5..eff30ad 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -21,6 +21,7 @@ import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult import cn.learningpad.oraltrainer.sdk.TrainingMediaItem import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary +import kotlin.math.roundToInt class MainActivity : Activity() { private lateinit var sdk: OralTrainerSdk @@ -362,12 +363,43 @@ class MainActivity : Activity() { if (!progressBarDragging) { bindings.timeText.text = "${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.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 { + 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?) { if (!bindings.hasPlayerChrome) return if (sentence == null) { @@ -399,9 +431,21 @@ class MainActivity : Activity() { override fun onSeekProgressPreview(progress: Int) { if (!bindings.hasPlayerChrome) return - val duration = controller.snapshot().durationMs + if (controller.config.continuousPlayback) { + val duration = controller.snapshot().durationMs + bindings.timeText.text = + "${PlaybackFormat.time(PlaybackFormat.seekPosition(progress, duration))} / " + + PlaybackFormat.time(duration) + return + } + val (index, sentence, count) = sentenceForProgress(progress) bindings.timeText.text = - "${PlaybackFormat.time(PlaybackFormat.seekPosition(progress, duration))} / ${PlaybackFormat.time(duration)}" + if (sentence == null) { + "暂无句子边界" + } else { + "第 ${index + 1}/$count 句 " + + "${PlaybackFormat.time(sentence.startMs)}-${PlaybackFormat.time(sentence.endMs)}" + } } override fun onSeekStart() { @@ -410,9 +454,13 @@ class MainActivity : Activity() { override fun onSeekStop(progress: Int) { progressBarDragging = false - controller.seekTo( - PlaybackFormat.seekPosition(progress, controller.snapshot().durationMs), - ) + if (controller.config.continuousPlayback) { + 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) { diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 9c54a81..2e0f7a3 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift index 1e0b15b..8f72914 100644 --- a/ios/OralTrainer/Views/PlayerSectionView.swift +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -233,6 +233,7 @@ struct SentenceExplanationSheet: View { let courseTitle: String let sentence: SentenceBoundary + @Environment(\.dismiss) private var dismiss @State private var explanation = "" @State private var errorMessage: String? @@ -264,6 +265,18 @@ struct SentenceExplanationSheet: View { .background(Color.white.ignoresSafeArea()) .navigationTitle("句子讲解") .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 { guard explanation.isEmpty, errorMessage == nil else { return }