Compare commits
91 Commits
bb677ea8cf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d143eb7fed | |||
| a87228cdc9 | |||
| 031f3c6f21 | |||
| 03e7987c7c | |||
| ed8df744f9 | |||
| 9b7c7d726b | |||
| fe21617c63 | |||
| 5428c42ffd | |||
| ae84347912 | |||
| 38894ba80c | |||
| 532d9900ba | |||
| 176d722c77 | |||
| 4746331515 | |||
| b28f809c46 | |||
| 4fd0fd36a4 | |||
| d5609c5c42 | |||
| beb29429d2 | |||
| 9daea7b807 | |||
| 6bd64ee4a9 | |||
| 138d446d73 | |||
| 7b63c4eab6 | |||
| 1a5a756e82 | |||
| 55fad2c0cd | |||
| 7fae2bb8c9 | |||
| 37ee850ee1 | |||
| 2a7f24b1bd | |||
| 3aaa9f4229 | |||
| 9c78bb9b04 | |||
| 53e947c506 | |||
| 5328898971 | |||
| f810cfedd4 | |||
| 1499db0ed7 | |||
| 5856832eca | |||
| 55d6860cc0 | |||
| ce8b765103 | |||
| 96d0ff5318 | |||
| 93232a64fa | |||
| e2cc793bdb | |||
| 8414f36cca | |||
| ab4ff6c25a | |||
| 67c7ad3d0f | |||
| 4b71c6c615 | |||
| 07261d092b | |||
| 2b663d50ad | |||
| 65b6617a5f | |||
| c841aa091d | |||
| f10915e020 | |||
| 9980f78cfc | |||
| 39896a5c24 | |||
| 3908947ac2 | |||
| 9d956183c7 | |||
| fb64343a9f | |||
| d0d43794f3 | |||
| 9230db3b69 | |||
| aca36c78c1 | |||
| 0baff0b447 | |||
| 701148e00e | |||
| 7d843e8ff1 | |||
| 6d9f9a5468 | |||
| f928dd5c68 | |||
| ff1341e191 | |||
| a38679d4a0 | |||
| cb87cf8cb1 | |||
| 1adba4d227 | |||
| 09fbe8a2de | |||
| b5934f21f5 | |||
| f9f65988b7 | |||
| b335430c18 | |||
| cb5573da73 | |||
| 53aed9e220 | |||
| 85a5e76d6f | |||
| daa73e2b98 | |||
| 3652ebda07 | |||
| 4a61afd5a9 | |||
| e80a770847 | |||
| a652a39b6c | |||
| 4ae77de307 | |||
| f2042e638f | |||
| 88811be98d | |||
| 7e4c0f5f90 | |||
| 637101bcdb | |||
| 0b85a2ed57 | |||
| 7cdc637527 | |||
| 0d0b82e095 | |||
| 508a26ba02 | |||
| a99c0cfd17 | |||
| abc052e42d | |||
| 997bd9eed8 | |||
| 856aa38026 | |||
| 6de1d4a3d0 | |||
| f93bb8d1ff |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ sentence_api/data/v/
|
||||
sentence_api/data/work/
|
||||
sentence_api/data/attempts/
|
||||
android/sample-app/release/
|
||||
|
||||
.tmp/
|
||||
|
||||
18
README.md
18
README.md
@@ -10,8 +10,17 @@ pip 安装依赖后即可直接播放视频。
|
||||
- 播放 / 暂停 / 停止、上一首 / 下一首
|
||||
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
|
||||
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
|
||||
- 按句跳转:勾选后 `←` / `→` 自动播放上一句/下一句(基于音频静音自动识别
|
||||
句边界,无需联网,分析结果自动缓存)
|
||||
- 按句跳转:先点“生成句边界”。生成时会先按 SHA-256 查询线上是否已有同一
|
||||
视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能
|
||||
勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256
|
||||
缓存结果;生成过程有进度条。
|
||||
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
|
||||
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
|
||||
- 导出字幕:句边界就绪后可点“导出字幕”,默认保存为 UTF-8(带 BOM)的 `.ass`(中英同一条字幕避免重叠),也可另存 `.srt`。
|
||||
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
||||
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
||||
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
||||
`~/.mediaplayer_logs/mediaplayer.log`,不包含登录令牌。
|
||||
- 音量调节、静音,0.5x - 2.0x 变速播放
|
||||
- 三种循环模式:关 / 单曲循环 / 列表循环
|
||||
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
||||
@@ -77,6 +86,8 @@ pip install -r requirements.txt
|
||||
| `N` / `P` | 下一首 / 上一首 |
|
||||
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
|
||||
| `L` | 切换循环模式(关 / 单曲 / 列表) |
|
||||
| `C` | 开 / 关中文字幕 |
|
||||
| 双击画面 | 进入 / 退出全屏 |
|
||||
| `F` | 视频全屏切换 |
|
||||
| `Esc` | 退出全屏 |
|
||||
| `Ctrl+O` | 打开文件(macOS 亦支持 `Command+O`) |
|
||||
@@ -98,7 +109,8 @@ pip install -r requirements.txt
|
||||
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
|
||||
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
|
||||
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
|
||||
“按句跳转”的开关状态同样会自动保存。
|
||||
“按句跳转”和“中文字幕”的开关状态同样会自动保存。句边界就绪后,原文会叠在
|
||||
视频画面底部;打开中文字幕时会向服务器请求翻译并一并显示。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
@@ -30,6 +33,15 @@ class OralTrainerController internal constructor(
|
||||
private var lastSentenceIndex: Int? = null
|
||||
private var stopAtSentenceEnd = false
|
||||
private var stopAtSentenceEndIndex: Int? = null
|
||||
private var baseItemIndex = 0
|
||||
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
|
||||
@@ -44,10 +56,10 @@ class OralTrainerController internal constructor(
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
enforceSentenceBoundary()
|
||||
enforceStopAtSentenceEnd()
|
||||
notifySentenceIfChanged()
|
||||
notifySnapshot()
|
||||
mainHandler.postDelayed(this, 250L)
|
||||
mainHandler.postDelayed(this, IDLE_POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +69,45 @@ class OralTrainerController internal constructor(
|
||||
)
|
||||
player = ExoPlayer.Builder(appContext)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.build()
|
||||
.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) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -71,7 +116,14 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
lastSentenceIndex = null
|
||||
if (updatingPlaybackWindow) {
|
||||
notifySnapshot()
|
||||
return
|
||||
}
|
||||
if (clippedSentence == null) {
|
||||
baseItemIndex = player.currentMediaItemIndex
|
||||
lastSentenceIndex = null
|
||||
}
|
||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||
notifySentenceIfChanged(force = true)
|
||||
notifySnapshot()
|
||||
@@ -129,9 +181,18 @@ class OralTrainerController internal constructor(
|
||||
lastSentenceIndex = null
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs))
|
||||
cancelClipStartWarmup(restoreVolume = true)
|
||||
cancelSentenceEndGuard()
|
||||
baseItemIndex = safeIndex
|
||||
clippedSentence = null
|
||||
unclippedDurationMs = -1L
|
||||
val startMs = max(0L, startPositionMs)
|
||||
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, startMs)
|
||||
player.prepare()
|
||||
player.playWhenReady = config.autoPlay
|
||||
applyLoopMode()
|
||||
lockSentenceAt(startMs)
|
||||
syncPlaybackWindow(startMs)
|
||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||
notifySentenceIfChanged(force = true)
|
||||
notifySnapshot()
|
||||
@@ -141,14 +202,46 @@ class OralTrainerController internal constructor(
|
||||
loadItems(listOf(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces sentences without preparing/reloading media, preserving playback
|
||||
* position and state so a background boundary refresh can apply immediately.
|
||||
*/
|
||||
fun updateSentences(mediaId: String, sentences: List<SentenceBoundary>): Boolean {
|
||||
val index = mediaItems.indexOfFirst { it.id == mediaId }
|
||||
if (index < 0) {
|
||||
return false
|
||||
}
|
||||
val updatedItem = mediaItems[index].copy(sentences = sentences)
|
||||
if (mediaItems[index] == updatedItem) {
|
||||
return true
|
||||
}
|
||||
mediaItems[index] = updatedItem
|
||||
if (index == baseItemIndex) {
|
||||
listeners.forEach { it.onMediaChanged(updatedItem) }
|
||||
lockSentenceAt(playbackPositionMs())
|
||||
syncPlaybackWindow()
|
||||
notifySentenceIfChanged(force = true)
|
||||
notifySnapshot()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
@@ -162,17 +255,26 @@ class OralTrainerController internal constructor(
|
||||
fun stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
cancelClipStartWarmup(restoreVolume = true)
|
||||
cancelSentenceEndGuard()
|
||||
player.stop()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.seekTo(max(0L, positionMs))
|
||||
cancelClipStartWarmup(restoreVolume = true)
|
||||
cancelSentenceEndGuard()
|
||||
val safePositionMs = max(0L, positionMs)
|
||||
lockSentenceAt(safePositionMs)
|
||||
syncPlaybackWindow(safePositionMs)
|
||||
seekInCurrentWindow(safePositionMs)
|
||||
notifySentenceIfChanged(force = true)
|
||||
notifySnapshot()
|
||||
}
|
||||
|
||||
fun seekRelative(deltaMs: Long) {
|
||||
seekTo(player.currentPosition + deltaMs)
|
||||
seekTo(playbackPositionMs() + deltaMs)
|
||||
}
|
||||
|
||||
fun rewind(stepMs: Long = config.defaultSeekStepMs) {
|
||||
@@ -184,11 +286,11 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
fun previousItem() {
|
||||
player.seekToPreviousMediaItem()
|
||||
switchBaseItem(baseItemIndex - 1)
|
||||
}
|
||||
|
||||
fun nextItem() {
|
||||
player.seekToNextMediaItem()
|
||||
switchBaseItem(baseItemIndex + 1)
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
@@ -202,40 +304,71 @@ 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
|
||||
}
|
||||
applySentenceRepeatMode()
|
||||
}
|
||||
|
||||
fun setSentenceMode(enabled: Boolean) {
|
||||
config = config.copy(sentenceMode = enabled)
|
||||
syncPlaybackWindow()
|
||||
}
|
||||
|
||||
fun setContinuousPlayback(enabled: Boolean) {
|
||||
config = config.copy(continuousPlayback = enabled)
|
||||
if (!enabled) {
|
||||
lockSentenceAt(playbackPositionMs())
|
||||
}
|
||||
syncPlaybackWindow()
|
||||
notifySentenceIfChanged(force = true)
|
||||
}
|
||||
|
||||
fun playCurrentSentenceAndStop() {
|
||||
val sentence = currentSentence() ?: return
|
||||
player.seekTo(sentence.startMs)
|
||||
playSentenceAndStop(sentence.startMs, sentence.index)
|
||||
}
|
||||
|
||||
/** 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 = sentence.index
|
||||
player.play()
|
||||
stopAtSentenceEndIndex = sentenceIndex
|
||||
lastSentenceIndex = sentenceIndex
|
||||
player.playWhenReady = false
|
||||
if (item != null && sentence != null) {
|
||||
if (!isCurrentClip(sentence)) {
|
||||
installSentenceClip(item, sentence, 0L)
|
||||
player.playWhenReady = false
|
||||
}
|
||||
playClippedSentenceFromStart()
|
||||
} else {
|
||||
cancelSentenceEndGuard()
|
||||
player.seekTo(max(0L, startMs))
|
||||
player.playWhenReady = true
|
||||
player.play()
|
||||
}
|
||||
notifySentenceIfChanged(force = true)
|
||||
notifySnapshot()
|
||||
}
|
||||
|
||||
fun currentTrainingItem(): TrainingMediaItem? {
|
||||
val index = player.currentMediaItemIndex
|
||||
return mediaItems.getOrNull(index)
|
||||
return mediaItems.getOrNull(baseItemIndex)
|
||||
}
|
||||
|
||||
fun currentSentence(): SentenceBoundary? {
|
||||
val item = currentTrainingItem() ?: return null
|
||||
val position = player.currentPosition
|
||||
return item.sentences.lastOrNull { sentence ->
|
||||
position >= sentence.startMs && position < sentence.endMs
|
||||
if (item.sentences.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
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 {
|
||||
@@ -243,11 +376,16 @@ class OralTrainerController internal constructor(
|
||||
if (item.sentences.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
val position = player.currentPosition
|
||||
val target = item.sentences
|
||||
.asReversed()
|
||||
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
||||
?: item.sentences.first()
|
||||
val target = if (shouldBoundCurrentSentence()) {
|
||||
val index = anchoredSentenceIndex(item) ?: 0
|
||||
item.sentences.getOrNull((index - 1).coerceAtLeast(0)) ?: item.sentences.first()
|
||||
} else {
|
||||
val position = playbackPositionMs()
|
||||
item.sentences
|
||||
.asReversed()
|
||||
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
||||
?: item.sentences.first()
|
||||
}
|
||||
seekTo(target.startMs)
|
||||
return true
|
||||
}
|
||||
@@ -257,10 +395,15 @@ class OralTrainerController internal constructor(
|
||||
if (item.sentences.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
val position = player.currentPosition
|
||||
val target = item.sentences.firstOrNull {
|
||||
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
|
||||
} ?: return false
|
||||
val target = if (shouldBoundCurrentSentence()) {
|
||||
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
|
||||
} ?: return false
|
||||
}
|
||||
seekTo(target.startMs)
|
||||
return true
|
||||
}
|
||||
@@ -317,12 +460,16 @@ class OralTrainerController internal constructor(
|
||||
|
||||
fun snapshot(): PlaybackSnapshot {
|
||||
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(
|
||||
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,
|
||||
@@ -335,6 +482,8 @@ class OralTrainerController internal constructor(
|
||||
return
|
||||
}
|
||||
released = true
|
||||
cancelClipStartWarmup(restoreVolume = false)
|
||||
cancelSentenceEndGuard()
|
||||
mainHandler.removeCallbacks(ticker)
|
||||
listeners.clear()
|
||||
player.release()
|
||||
@@ -358,42 +507,291 @@ class OralTrainerController internal constructor(
|
||||
listeners.forEach { it.onSentenceChanged(sentence) }
|
||||
}
|
||||
|
||||
private fun enforceSentenceBoundary() {
|
||||
if (!config.sentenceMode || !player.isPlaying) {
|
||||
private fun syncPlaybackWindow(positionMs: Long = playbackPositionMs()) {
|
||||
if (released || updatingPlaybackWindow) {
|
||||
return
|
||||
}
|
||||
val item = currentTrainingItem() ?: return
|
||||
if (item.sentences.isEmpty()) {
|
||||
val item = currentTrainingItem()
|
||||
val target = sentenceForWindow(item)
|
||||
if (target == null || item == null) {
|
||||
restoreUnclipped(positionMs)
|
||||
return
|
||||
}
|
||||
val position = player.currentPosition
|
||||
if (stopAtSentenceEnd) {
|
||||
val index = stopAtSentenceEndIndex ?: return
|
||||
val sentence = item.sentences.getOrNull(index) ?: return
|
||||
if (position >= sentence.endMs) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.pause()
|
||||
player.seekTo(sentence.startMs)
|
||||
val current = clippedSentence
|
||||
if (current != null &&
|
||||
current.index == target.index &&
|
||||
current.startMs == target.startMs &&
|
||||
current.endMs == target.endMs
|
||||
) {
|
||||
applySentenceRepeatMode()
|
||||
return
|
||||
}
|
||||
val relativeMs = (positionMs - target.startMs).coerceAtLeast(0L)
|
||||
installSentenceClip(item, target, relativeMs)
|
||||
}
|
||||
|
||||
private fun sentenceForWindow(item: TrainingMediaItem?): SentenceBoundary? {
|
||||
if (item == null || !config.sentenceMode || item.sentences.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return when {
|
||||
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null)
|
||||
config.continuousPlayback -> null
|
||||
else -> {
|
||||
val index = lastSentenceIndex ?: sentenceAt(item, playbackPositionMs())?.index
|
||||
item.sentences.getOrNull(index ?: return null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (config.continuousPlayback) {
|
||||
return
|
||||
}
|
||||
val anchorIndex = lastSentenceIndex ?: return
|
||||
val anchor = item.sentences.getOrNull(anchorIndex) ?: return
|
||||
if (position >= anchor.endMs) {
|
||||
player.seekTo(anchor.startMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TrainingMediaItem.toMedia3Item(): MediaItem {
|
||||
private fun installSentenceClip(
|
||||
item: TrainingMediaItem,
|
||||
sentence: SentenceBoundary,
|
||||
relativePositionMs: Long,
|
||||
) {
|
||||
if (released) {
|
||||
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 = false
|
||||
applySentenceRepeatMode()
|
||||
updatingPlaybackWindow = false
|
||||
if (playWhenReady) {
|
||||
if (warmFromStart) {
|
||||
playClippedSentenceFromStart()
|
||||
} else {
|
||||
player.playWhenReady = true
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreUnclipped(positionMs: Long) {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
if (clippedSentence == null && player.mediaItemCount == mediaItems.size) {
|
||||
applyLoopMode()
|
||||
return
|
||||
}
|
||||
val playWhenReady = player.playWhenReady
|
||||
updatingPlaybackWindow = true
|
||||
clippedSentence = null
|
||||
clipStartNeedsWarmup = false
|
||||
player.setMediaItems(
|
||||
mediaItems.map { it.toMedia3Item() },
|
||||
baseItemIndex.coerceIn(0, mediaItems.lastIndex),
|
||||
max(0L, positionMs),
|
||||
)
|
||||
player.prepare()
|
||||
player.playWhenReady = playWhenReady
|
||||
applyLoopMode()
|
||||
updatingPlaybackWindow = false
|
||||
}
|
||||
|
||||
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
|
||||
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) {
|
||||
handleStopAtSentenceEnd(sentence)
|
||||
return
|
||||
}
|
||||
clipStartNeedsWarmup = false
|
||||
player.seekTo(0L)
|
||||
if (player.playbackState == Player.STATE_ENDED) {
|
||||
player.prepare()
|
||||
}
|
||||
if (!config.continuousPlayback) {
|
||||
player.play()
|
||||
}
|
||||
lastSentenceIndex = sentence.index
|
||||
notifySentenceIfChanged(force = true)
|
||||
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
|
||||
}
|
||||
baseItemIndex = targetIndex
|
||||
lastSentenceIndex = null
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
cancelClipStartWarmup(restoreVolume = true)
|
||||
cancelSentenceEndGuard()
|
||||
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()
|
||||
.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(
|
||||
@@ -422,8 +820,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import android.widget.FrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
@@ -16,6 +17,7 @@ class OralTrainerPlayerView @JvmOverloads constructor(
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
val playerView: PlayerView = PlayerView(context).apply {
|
||||
useController = false
|
||||
setKeepContentOnPlayerReset(true)
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
@@ -122,6 +124,26 @@ class OralTrainerPlayerView @JvmOverloads constructor(
|
||||
verticalSwipeListener = listener
|
||||
}
|
||||
|
||||
/** Forwards touches from an overlay (for example a subtitle layer) as player gestures. */
|
||||
fun forwardGestureFrom(source: View, event: MotionEvent): Boolean {
|
||||
if (controller == null || !gestureControls.enabled) {
|
||||
return false
|
||||
}
|
||||
val sourceLocation = IntArray(2).also(source::getLocationOnScreen)
|
||||
val playerLocation = IntArray(2).also(::getLocationOnScreen)
|
||||
val playerEvent = MotionEvent.obtain(event).apply {
|
||||
offsetLocation(
|
||||
(playerLocation[0] - sourceLocation[0]).toFloat(),
|
||||
(playerLocation[1] - sourceLocation[1]).toFloat(),
|
||||
)
|
||||
}
|
||||
return try {
|
||||
handleLongPressSpeed(playerEvent) || gestureDetector.onTouchEvent(playerEvent)
|
||||
} finally {
|
||||
playerEvent.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class OralTrainerSdk private constructor(
|
||||
|
||||
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
|
||||
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
|
||||
val userApi: UserApi = UserApi(appContext, config)
|
||||
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
|
||||
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
|
||||
|
||||
|
||||
@@ -10,5 +10,6 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
|
||||
val readTimeoutMs: Int = 30_000,
|
||||
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
||||
val assessmentApiKey: String? = null,
|
||||
var userToken: String? = null,
|
||||
val allowHttpFallback: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -89,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
}
|
||||
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
|
||||
@@ -111,8 +111,12 @@ class SentenceBoundaryApi internal constructor(
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
@@ -155,6 +159,11 @@ class SentenceBoundaryApi internal constructor(
|
||||
} else {
|
||||
sentence.getLong("reference_speech_duration_ms")
|
||||
},
|
||||
zhText = if (sentence.isNull("zh_text")) {
|
||||
null
|
||||
} else {
|
||||
sentence.getString("zh_text").takeIf { it.isNotBlank() }
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ data class SentenceBoundary @JvmOverloads constructor(
|
||||
val text: String? = null,
|
||||
val language: String? = null,
|
||||
val referenceSpeechDurationMs: Long? = null,
|
||||
val zhText: String? = null,
|
||||
) {
|
||||
init {
|
||||
require(index >= 0) { "Sentence index must be non-negative." }
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
package cn.learningpad.oraltrainer.sdk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class AuthUser(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val nickname: String,
|
||||
)
|
||||
|
||||
data class AuthSession(
|
||||
val token: String,
|
||||
val user: AuthUser,
|
||||
)
|
||||
|
||||
data class Course(
|
||||
val videoHash: String,
|
||||
val title: String,
|
||||
val durationMs: Long?,
|
||||
val language: String?,
|
||||
val sentenceCount: Int,
|
||||
val streamUrl: String,
|
||||
val enrolled: Boolean,
|
||||
) {
|
||||
fun toVideoSummary(): TrainingVideoSummary {
|
||||
return TrainingVideoSummary(
|
||||
videoHash = videoHash,
|
||||
title = title,
|
||||
streamUri = android.net.Uri.parse(streamUrl),
|
||||
durationMs = durationMs,
|
||||
sizeBytes = 0L,
|
||||
language = language,
|
||||
sentenceCount = sentenceCount,
|
||||
status = "ready",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UserResult(
|
||||
val attemptId: String,
|
||||
val videoHash: String,
|
||||
val courseTitle: String,
|
||||
val sentenceIndex: Int,
|
||||
val sentenceText: String,
|
||||
val overallScore: Float,
|
||||
val passed: Boolean,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class UserDubShare(
|
||||
val shareId: String,
|
||||
val videoHash: String,
|
||||
val courseTitle: String,
|
||||
val title: String,
|
||||
val segmentCount: Int,
|
||||
val averageScore: Float,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class DubShareSegmentDetail(
|
||||
val index: Int,
|
||||
val overallScore: Float,
|
||||
val recognizedText: String,
|
||||
val scoreDetails: Map<String, Float>,
|
||||
val audioUrl: String,
|
||||
)
|
||||
|
||||
data class DubShareDetail(
|
||||
val shareId: String,
|
||||
val videoHash: String,
|
||||
val title: String,
|
||||
val segments: List<DubShareSegmentDetail>,
|
||||
)
|
||||
|
||||
class UserApiException(
|
||||
val statusCode: Int,
|
||||
responseBody: String,
|
||||
) : IOException("HTTP $statusCode: ${responseBody.take(300)}")
|
||||
|
||||
class UserApi internal constructor(
|
||||
context: Context,
|
||||
private val config: OralTrainerSdkConfig,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val baseUri = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||
|
||||
init {
|
||||
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
|
||||
"sentenceBoundaryApiBaseUrl must use http or https."
|
||||
}
|
||||
}
|
||||
|
||||
fun register(
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String? = null,
|
||||
phone: String,
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
requestRegisterBlocking(
|
||||
"api/v1/auth/register",
|
||||
username = username,
|
||||
password = password,
|
||||
nickname = nickname,
|
||||
phone = phone,
|
||||
)
|
||||
}
|
||||
|
||||
fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) }
|
||||
|
||||
fun ssoExchange(
|
||||
accessToken: String,
|
||||
onComplete: (Result<SsoExchangeResult>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
val root = JSONObject().put("access_token", accessToken)
|
||||
val response = request("api/v1/auth/sso/exchange", body = root.toString(), method = "POST")
|
||||
parseSsoExchangeResult(JSONObject(response))
|
||||
}
|
||||
|
||||
fun ssoBindPhone(
|
||||
token: String,
|
||||
phone: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
val root = JSONObject().put("phone", phone)
|
||||
request("api/v1/auth/sso/bind-phone", token = token, body = root.toString(), method = "POST")
|
||||
}
|
||||
|
||||
fun courses(
|
||||
token: String,
|
||||
onComplete: (Result<List<Course>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "api/v1/courses")) }
|
||||
|
||||
fun enroll(
|
||||
token: String,
|
||||
videoHash: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("api/v1/courses/$videoHash/enroll", token, method = "POST")
|
||||
}
|
||||
|
||||
fun results(
|
||||
token: String,
|
||||
onComplete: (Result<List<UserResult>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "api/v1/me/results")) }
|
||||
|
||||
fun dubShares(
|
||||
token: String,
|
||||
onComplete: (Result<List<UserDubShare>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) }
|
||||
|
||||
fun deleteResult(
|
||||
token: String,
|
||||
attemptId: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("api/v1/me/results/$attemptId", token, method = "DELETE")
|
||||
}
|
||||
|
||||
fun deleteDubShare(
|
||||
token: String,
|
||||
shareId: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("api/v1/me/dub-shares/$shareId", token, method = "DELETE")
|
||||
}
|
||||
|
||||
fun dubShareDetail(
|
||||
shareId: String,
|
||||
onComplete: (Result<DubShareDetail>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
parseDubShareDetail(JSONObject(request("api/v1/dub-shares/$shareId", method = "GET")))
|
||||
}
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun <T> submit(
|
||||
onComplete: (Result<T>) -> Unit,
|
||||
operation: () -> T,
|
||||
): CancellableRequest {
|
||||
val future: Future<*> = executor.submit {
|
||||
post(onComplete, runCatching(operation))
|
||||
}
|
||||
return CancellableRequest(future)
|
||||
}
|
||||
|
||||
private fun <T> submitObject(
|
||||
onComplete: (Result<T>) -> Unit,
|
||||
token: String,
|
||||
operation: (String) -> T,
|
||||
): CancellableRequest = submit(onComplete) { operation(token) }
|
||||
|
||||
private fun submitUnit(
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
token: String,
|
||||
operation: (String) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
operation(token)
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun <T> post(onComplete: (Result<T>) -> Unit, result: Result<T>) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { onComplete(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBlocking(path: String, username: String, password: String, auth: Boolean): AuthSession {
|
||||
val root = JSONObject().put("username", username).put("password", password)
|
||||
val response = request(path, body = root.toString(), method = "POST")
|
||||
return parseSession(JSONObject(response))
|
||||
}
|
||||
|
||||
private fun requestRegisterBlocking(
|
||||
path: String,
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String?,
|
||||
phone: String,
|
||||
): AuthSession {
|
||||
val cleanedPhone = phone.trim()
|
||||
require(cleanedPhone.isNotEmpty()) { "请填写手机号" }
|
||||
require(PHONE_PATTERN.matches(cleanedPhone)) { "请输入有效的 11 位手机号" }
|
||||
val root = JSONObject()
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) }
|
||||
root.put("phone", cleanedPhone)
|
||||
val response = request(path, body = root.toString(), method = "POST")
|
||||
return parseSession(JSONObject(response))
|
||||
}
|
||||
|
||||
private fun getBlocking(token: String, path: String): JSONArray {
|
||||
val body = request(path, token = token, method = "GET")
|
||||
return JSONArray(body)
|
||||
}
|
||||
|
||||
private fun parseCourses(items: JSONArray): List<Course> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
Course(
|
||||
videoHash = item.getString("video_hash"),
|
||||
title = item.getString("title"),
|
||||
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
|
||||
language = if (item.isNull("language")) null else item.getString("language"),
|
||||
sentenceCount = item.getInt("sentence_count"),
|
||||
streamUrl = item.getString("stream_url"),
|
||||
enrolled = item.getBoolean("enrolled"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseResults(items: JSONArray): List<UserResult> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
UserResult(
|
||||
attemptId = item.getString("attempt_id"),
|
||||
videoHash = item.getString("video_hash"),
|
||||
courseTitle = item.getString("course_title"),
|
||||
sentenceIndex = item.getInt("sentence_index"),
|
||||
sentenceText = item.getString("sentence_text"),
|
||||
overallScore = item.getDouble("overall_score").toFloat(),
|
||||
passed = item.getBoolean("passed"),
|
||||
createdAt = item.getString("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDubShares(items: JSONArray): List<UserDubShare> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
UserDubShare(
|
||||
shareId = item.getString("share_id"),
|
||||
videoHash = item.getString("video_hash"),
|
||||
courseTitle = item.getString("course_title"),
|
||||
title = item.getString("title"),
|
||||
segmentCount = item.getInt("segment_count"),
|
||||
averageScore = item.getDouble("average_score").toFloat(),
|
||||
createdAt = item.getString("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDubShareDetail(root: JSONObject): DubShareDetail {
|
||||
val segmentsJson = root.optJSONArray("segments") ?: JSONArray()
|
||||
val segments = List(segmentsJson.length()) { index ->
|
||||
val item = segmentsJson.getJSONObject(index)
|
||||
val detailsJson = item.optJSONObject("score_details") ?: JSONObject()
|
||||
val scoreDetails = buildMap<String, Float> {
|
||||
for (key in detailsJson.keys()) {
|
||||
if (!detailsJson.isNull(key)) {
|
||||
put(key, detailsJson.optDouble(key).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
DubShareSegmentDetail(
|
||||
index = item.getInt("index"),
|
||||
overallScore = item.optDouble("overall_score", 0.0).toFloat(),
|
||||
recognizedText = item.optString("recognized_text"),
|
||||
scoreDetails = scoreDetails,
|
||||
audioUrl = item.getString("audio_url"),
|
||||
)
|
||||
}
|
||||
return DubShareDetail(
|
||||
shareId = root.getString("share_id"),
|
||||
videoHash = root.getString("video_hash"),
|
||||
title = root.optString("title"),
|
||||
segments = segments,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
path: String,
|
||||
token: String? = null,
|
||||
body: String? = null,
|
||||
method: String = body?.let { "POST" } ?: "GET",
|
||||
): String {
|
||||
val endpoint = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||
.buildUpon().appendEncodedPath(path).build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
token?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw UserApiException(statusCode, text)
|
||||
return text
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSession(root: JSONObject): AuthSession {
|
||||
val user = root.getJSONObject("user")
|
||||
return AuthSession(
|
||||
token = root.getString("token"),
|
||||
user = AuthUser(
|
||||
id = user.getString("id"),
|
||||
username = user.getString("username"),
|
||||
nickname = user.getString("nickname"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSsoExchangeResult(root: JSONObject): SsoExchangeResult {
|
||||
val user = root.getJSONObject("user")
|
||||
return SsoExchangeResult(
|
||||
token = root.getString("token"),
|
||||
user = AuthUser(
|
||||
id = user.getString("id"),
|
||||
username = user.getString("username"),
|
||||
nickname = user.getString("nickname"),
|
||||
),
|
||||
phoneBound = root.optBoolean("phone_bound", false),
|
||||
isNewUser = root.optBoolean("is_new_user", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SsoExchangeResult(
|
||||
val token: String,
|
||||
val user: AuthUser,
|
||||
val phoneBound: Boolean,
|
||||
val isNewUser: Boolean,
|
||||
)
|
||||
|
||||
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")
|
||||
@@ -19,7 +19,7 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.1"
|
||||
versionName = "0.1.6"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -37,6 +37,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":oral-trainer-sdk"))
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.media3:media3-common:1.11.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.11.0")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -19,6 +22,11 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".TrainingPlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
enum class Module {
|
||||
COURSE,
|
||||
TRAIN,
|
||||
READING,
|
||||
TEST,
|
||||
MINE,
|
||||
}
|
||||
|
||||
internal const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
|
||||
internal const val ASSESSMENT_API_KEY =
|
||||
"fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
|
||||
|
||||
internal const val SSO_LOGIN_URL = "https://d1kt.cn/login"
|
||||
|
||||
internal const val RECORD_AUDIO_REQUEST = 1002
|
||||
internal const val PROGRESS_MAX = 1000
|
||||
|
||||
internal val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
|
||||
internal class AssessmentState {
|
||||
private val scores = mutableMapOf<Int, ImitationAssessmentResult>()
|
||||
|
||||
fun store(sentenceIndex: Int, result: ImitationAssessmentResult) {
|
||||
scores[sentenceIndex] = result
|
||||
}
|
||||
|
||||
fun get(sentenceIndex: Int): ImitationAssessmentResult? = scores[sentenceIndex]
|
||||
|
||||
fun replaceAll(values: Map<Int, ImitationAssessmentResult>) {
|
||||
scores.clear()
|
||||
scores.putAll(values)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
scores.clear()
|
||||
}
|
||||
|
||||
fun toMap(): Map<Int, ImitationAssessmentResult> = scores.toMap()
|
||||
|
||||
val size: Int
|
||||
get() = scores.size
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
|
||||
internal class AttemptRecorder(private val context: Context) {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var file: File? = null
|
||||
|
||||
val isRecording: Boolean
|
||||
get() = recorder != null
|
||||
|
||||
fun start(cacheDir: File): File {
|
||||
release()
|
||||
|
||||
val outputFile = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a")
|
||||
val mediaRecorder = createRecorder()
|
||||
try {
|
||||
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
mediaRecorder.setAudioSamplingRate(16_000)
|
||||
mediaRecorder.setAudioEncodingBitRate(96_000)
|
||||
mediaRecorder.setOutputFile(outputFile.absolutePath)
|
||||
mediaRecorder.prepare()
|
||||
mediaRecorder.start()
|
||||
recorder = mediaRecorder
|
||||
file = outputFile
|
||||
return outputFile
|
||||
} catch (error: Throwable) {
|
||||
runCatching { mediaRecorder.release() }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): File {
|
||||
val activeRecorder = requireNotNull(recorder) { "当前没有录音" }
|
||||
val outputFile = requireNotNull(file) { "录音文件不存在" }
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
return outputFile
|
||||
} catch (error: Throwable) {
|
||||
outputFile.delete()
|
||||
throw error
|
||||
} finally {
|
||||
runCatching { activeRecorder.release() }
|
||||
recorder = null
|
||||
file = null
|
||||
}
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
recorder?.let { mediaRecorder ->
|
||||
runCatching { mediaRecorder.stop() }
|
||||
runCatching { mediaRecorder.release() }
|
||||
}
|
||||
recorder = null
|
||||
file?.delete()
|
||||
file = null
|
||||
}
|
||||
|
||||
fun release() = discard()
|
||||
|
||||
private fun createRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
import cn.learningpad.oraltrainer.sdk.AuthUser
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
internal class AuthCoordinator(
|
||||
private val activity: Activity,
|
||||
private val userApi: UserApi,
|
||||
private val onAuthenticated: (AuthSession) -> Unit,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private var screen = Screen.LOGIN
|
||||
private var pendingSsoToken: String? = null
|
||||
private var webView: WebView? = null
|
||||
private var loginStatusMessage = ""
|
||||
|
||||
fun createView(): View = when (screen) {
|
||||
Screen.LOGIN -> createLoginView()
|
||||
Screen.REGISTER -> createRegisterView()
|
||||
Screen.SSO -> createSsoView()
|
||||
Screen.PHONE_BINDING -> createPhoneBindingView()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
pendingSsoToken = null
|
||||
loginStatusMessage = ""
|
||||
screen = Screen.LOGIN
|
||||
}
|
||||
|
||||
private fun createLoginView(): View = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
setPadding(28.dp, 28.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(TextView(activity).apply {
|
||||
text = "八哥口语"
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "请先注册或登录后开始学习"
|
||||
textSize = 15f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
})
|
||||
|
||||
val usernameInput = EditText(activity).apply {
|
||||
hint = "用户名"
|
||||
setSingleLine(true)
|
||||
}
|
||||
val passwordInput = EditText(activity).apply {
|
||||
hint = "密码(至少 8 位)"
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
setSingleLine(true)
|
||||
}
|
||||
val statusText = statusText(loginStatusMessage)
|
||||
addView(usernameInput)
|
||||
addView(passwordInput)
|
||||
addView(primaryButton("登录") {
|
||||
statusText.text = "登录中..."
|
||||
userApi.login(
|
||||
usernameInput.text?.toString()?.trim().orEmpty(),
|
||||
passwordInput.text?.toString().orEmpty(),
|
||||
) { result ->
|
||||
result.fold(
|
||||
onSuccess = onAuthenticated,
|
||||
onFailure = { error -> statusText.text = "登录失败:${error.message}" },
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).withMargins(0, 20.dp, 0, 8.dp)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "注册"
|
||||
setOnClickListener {
|
||||
screen = Screen.REGISTER
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "── 或 ──"
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 12.dp, 0, 12.dp)
|
||||
})
|
||||
addView(primaryButton("第一课堂一键登录", SampleTheme.button) {
|
||||
screen = Screen.SSO
|
||||
activity.setContentView(createView())
|
||||
})
|
||||
addView(statusText)
|
||||
}
|
||||
|
||||
private fun createRegisterView(): View = ScrollView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(title("注册账号", 28f))
|
||||
addView(subtitle("填写以下信息完成注册"))
|
||||
|
||||
val usernameInput = labeledInput("用户名", "3-50 位字母、数字或下划线")
|
||||
val passwordInput = labeledInput(
|
||||
"密码",
|
||||
"至少 8 位",
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD,
|
||||
)
|
||||
val nicknameInput = labeledInput("姓名", "你的真实姓名或昵称")
|
||||
val phoneInput = labeledInput("手机号", "请输入 11 位手机号", InputType.TYPE_CLASS_PHONE)
|
||||
val statusText = statusText()
|
||||
|
||||
addView(primaryButton("注册") {
|
||||
val username = usernameInput.text?.toString()?.trim().orEmpty()
|
||||
val password = passwordInput.text?.toString().orEmpty()
|
||||
val nickname = nicknameInput.text?.toString()?.trim().orEmpty()
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
when {
|
||||
username.length < 3 -> statusText.text = "用户名至少 3 个字符"
|
||||
password.length < 8 -> statusText.text = "密码至少 8 位"
|
||||
nickname.isBlank() -> statusText.text = "请填写姓名"
|
||||
!PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号"
|
||||
else -> {
|
||||
statusText.text = "注册中..."
|
||||
userApi.register(username, password, nickname, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = onAuthenticated,
|
||||
onFailure = { error ->
|
||||
statusText.text = "注册失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(0, 20.dp, 0, 8.dp)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "返回登录"
|
||||
setOnClickListener {
|
||||
screen = Screen.LOGIN
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
addView(statusText)
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun createSsoView(): View = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
|
||||
addView(Button(activity).apply {
|
||||
text = "取消登录"
|
||||
setOnClickListener {
|
||||
leaveSso(Screen.LOGIN)
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
|
||||
webView = WebView(activity).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
loadUrl(SSO_LOGIN_URL)
|
||||
}
|
||||
addView(
|
||||
webView,
|
||||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f),
|
||||
)
|
||||
addView(primaryButton("已登录,点击继续") { readJwtAndLogin() }.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(8.dp, 8.dp, 8.dp, 8.dp)
|
||||
})
|
||||
}
|
||||
|
||||
private fun readJwtAndLogin() {
|
||||
webView?.evaluateJavascript(JWT_SCRIPT) { result ->
|
||||
val jwt = result?.removeSurrounding("\"").orEmpty()
|
||||
if (jwt.isBlank() || jwt == "null") {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, "未找到登录凭证,请先在页面中登录", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@evaluateJavascript
|
||||
}
|
||||
userApi.ssoExchange(jwt) { exchangeResult ->
|
||||
exchangeResult.fold(
|
||||
onSuccess = { ssoResult ->
|
||||
activity.runOnUiThread {
|
||||
leaveSso(if (ssoResult.phoneBound) Screen.LOGIN else Screen.PHONE_BINDING)
|
||||
pendingSsoToken = ssoResult.token
|
||||
if (ssoResult.phoneBound) {
|
||||
onAuthenticated(AuthSession(ssoResult.token, ssoResult.user))
|
||||
} else {
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
activity.runOnUiThread {
|
||||
loginStatusMessage = "SSO 登录失败:${error.message}"
|
||||
leaveSso(Screen.LOGIN)
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPhoneBindingView(): View = ScrollView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(title("绑定手机号", 28f))
|
||||
addView(subtitle("已通过第一课堂登录,请绑定手机号后继续"))
|
||||
val phoneInput = labeledInput(
|
||||
"手机号",
|
||||
"请输入 11 位手机号",
|
||||
InputType.TYPE_CLASS_PHONE,
|
||||
)
|
||||
val statusText = statusText()
|
||||
addView(primaryButton("绑定并继续") {
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
val token = pendingSsoToken
|
||||
when {
|
||||
!PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号"
|
||||
token.isNullOrBlank() -> statusText.text = "登录状态已过期,请重新登录"
|
||||
else -> {
|
||||
statusText.text = "绑定中..."
|
||||
userApi.ssoBindPhone(token, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
pendingSsoToken = null
|
||||
screen = Screen.LOGIN
|
||||
onAuthenticated(AuthSession(token, AuthUser("", "", "")))
|
||||
},
|
||||
onFailure = { error ->
|
||||
statusText.text = "绑定失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(0, 20.dp, 0, 0)
|
||||
})
|
||||
addView(statusText)
|
||||
})
|
||||
}
|
||||
|
||||
private fun LinearLayout.labeledInput(
|
||||
label: String,
|
||||
hint: String,
|
||||
inputType: Int? = null,
|
||||
): EditText {
|
||||
addView(TextView(activity).apply {
|
||||
text = label
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 12.dp, 0, 4.dp)
|
||||
})
|
||||
return EditText(activity).apply {
|
||||
this.hint = hint
|
||||
inputType?.let { this.inputType = it }
|
||||
setSingleLine(true)
|
||||
}.also(::addView)
|
||||
}
|
||||
|
||||
private fun title(value: String, size: Float) = TextView(activity).apply {
|
||||
text = value
|
||||
textSize = size
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
}
|
||||
|
||||
private fun subtitle(value: String) = TextView(activity).apply {
|
||||
text = value
|
||||
textSize = 14f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
}
|
||||
|
||||
private fun statusText(value: String = "") = TextView(activity).apply {
|
||||
text = value
|
||||
setTextColor(SampleTheme.accentLight)
|
||||
textSize = 13f
|
||||
setPadding(0, 14.dp, 0, 0)
|
||||
}
|
||||
|
||||
private fun primaryButton(
|
||||
label: String,
|
||||
color: Int = SampleTheme.accent,
|
||||
action: () -> Unit,
|
||||
) = Button(activity).apply {
|
||||
text = label
|
||||
background = rounded(color, 8f)
|
||||
setTextColor(Color.WHITE)
|
||||
setOnClickListener { action() }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
}
|
||||
|
||||
private fun leaveSso(destination: Screen) {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
screen = destination
|
||||
}
|
||||
|
||||
private enum class Screen {
|
||||
LOGIN,
|
||||
REGISTER,
|
||||
SSO,
|
||||
PHONE_BINDING,
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")
|
||||
private const val JWT_SCRIPT =
|
||||
"(function(){ try { return localStorage.getItem('token') || " +
|
||||
"sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
|
||||
internal class AuthSessionStore(context: Context) {
|
||||
private val preferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var session: AuthSession?
|
||||
get() {
|
||||
val token = preferences.getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val nickname = preferences.getString(KEY_NICKNAME, "").orEmpty()
|
||||
return AuthSession(token, cn.learningpad.oraltrainer.sdk.AuthUser("", "", nickname))
|
||||
}
|
||||
set(value) {
|
||||
val editor = preferences.edit()
|
||||
if (value == null) {
|
||||
editor.clear()
|
||||
} else {
|
||||
editor.putString(KEY_TOKEN, value.token)
|
||||
editor.putString(KEY_NICKNAME, value.user.nickname)
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean
|
||||
get() = session != null
|
||||
|
||||
private companion object {
|
||||
private const val PREFERENCES_NAME = "oral_trainer_auth"
|
||||
private const val KEY_TOKEN = "token"
|
||||
private const val KEY_NICKNAME = "nickname"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
/**
|
||||
* Owns course list loading, enrollment, and remote sentence-boundary refresh.
|
||||
* UI updates stay in the host via [Host].
|
||||
*/
|
||||
internal class CourseCatalogController(
|
||||
private val sdk: OralTrainerSdk,
|
||||
private val controller: OralTrainerController,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val tokenProvider: () -> String,
|
||||
private val host: Host,
|
||||
) {
|
||||
interface Host {
|
||||
fun onCatalogChanged()
|
||||
fun onCatalogStatus(text: String)
|
||||
fun onCatalogLoadingPlaceholder()
|
||||
fun onPlayerLoading(video: TrainingVideoSummary)
|
||||
fun onStatus(text: String)
|
||||
fun onSentencePlaceholder(meta: String, text: String)
|
||||
fun refreshPlayerUi()
|
||||
fun openTrainingModule()
|
||||
fun syncSentenceExplanations(videoHash: String, sentences: List<SentenceBoundary>)
|
||||
}
|
||||
|
||||
fun loadCatalog() {
|
||||
loadCatalog(refreshBoundaries = false)
|
||||
}
|
||||
|
||||
fun refreshCourses() {
|
||||
if (screenState.catalogIsRefreshing) return
|
||||
loadCatalog(refreshBoundaries = true)
|
||||
}
|
||||
|
||||
private fun loadCatalog(refreshBoundaries: Boolean) {
|
||||
val generation = screenState.beginCatalogRequest()
|
||||
screenState.catalogIsRefreshing = refreshBoundaries
|
||||
screenState.catalogStatus = "正在同步"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogLoadingPlaceholder()
|
||||
sdk.userApi.courses(tokenProvider()) { result ->
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return@courses
|
||||
result.fold(
|
||||
onSuccess = { courses ->
|
||||
screenState.catalogVideos = courses.map { it.toVideoSummary() }
|
||||
screenState.courseEnrollment.clear()
|
||||
courses.forEach { screenState.courseEnrollment[it.videoHash] = it.enrolled }
|
||||
if (refreshBoundaries) {
|
||||
screenState.catalogStatus = "${courses.size} 个课程,正在刷新句界"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
refreshAllCourseBoundaries(generation, index = 0, succeeded = 0, failed = 0)
|
||||
} else {
|
||||
screenState.catalogIsRefreshing = false
|
||||
screenState.catalogStatus = "${screenState.catalogVideos.size} 个课程"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
screenState.catalogIsRefreshing = false
|
||||
screenState.catalogStatus = "课程加载失败:${it.message}"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
screenState.courseEnrollment.clear()
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAllCourseBoundaries(
|
||||
generation: Int,
|
||||
index: Int,
|
||||
succeeded: Int,
|
||||
failed: Int,
|
||||
) {
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return
|
||||
val videos = screenState.catalogVideos
|
||||
if (index >= videos.size) {
|
||||
screenState.catalogIsRefreshing = false
|
||||
screenState.catalogStatus = when {
|
||||
failed == 0 -> "${videos.size} 个课程 · 句界已刷新"
|
||||
succeeded == 0 -> "${videos.size} 个课程 · 句界刷新失败"
|
||||
else -> "${videos.size} 个课程 · 句界部分刷新(失败 $failed)"
|
||||
}
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
return
|
||||
}
|
||||
|
||||
val video = videos[index]
|
||||
screenState.catalogStatus =
|
||||
"正在刷新句界 ${index + 1}/${videos.size}"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
|
||||
override fun onSuccess(result: SentenceBoundaryApiResult) {
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return
|
||||
screenState.courseBoundaries[result.videoHash] = result
|
||||
host.syncSentenceExplanations(result.videoHash, result.sentences)
|
||||
updateCatalogVideo(video.videoHash, result)
|
||||
if (screenState.activeItemId == result.videoHash) {
|
||||
controller.updateSentences(result.videoHash, result.sentences)
|
||||
screenState.currentSentenceCount = result.sentences.size
|
||||
host.refreshPlayerUi()
|
||||
}
|
||||
host.onCatalogChanged()
|
||||
refreshAllCourseBoundaries(generation, index + 1, succeeded + 1, failed)
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return
|
||||
refreshAllCourseBoundaries(generation, index + 1, succeeded, failed + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateCatalogVideo(videoHash: String, result: SentenceBoundaryApiResult) {
|
||||
screenState.catalogVideos = screenState.catalogVideos.map { video ->
|
||||
if (video.videoHash != videoHash) {
|
||||
video
|
||||
} else {
|
||||
video.copy(
|
||||
durationMs = result.durationMs,
|
||||
sentenceCount = result.sentences.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (screenState.currentCourse?.videoHash == videoHash) {
|
||||
screenState.currentCourse = screenState.catalogVideos.firstOrNull { it.videoHash == videoHash }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUserData(target: Module, onMine: () -> Unit) {
|
||||
when (target) {
|
||||
Module.COURSE -> loadCatalog()
|
||||
Module.MINE -> onMine()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCourse(video: TrainingVideoSummary) {
|
||||
screenState.currentCourse = video
|
||||
if (screenState.courseEnrollment[video.videoHash] != true) {
|
||||
enrollCourse(video)
|
||||
}
|
||||
screenState.activeModule = Module.TRAIN
|
||||
screenState.landscapeSubtitleVisible = true
|
||||
host.openTrainingModule()
|
||||
loadRemoteVideo(video)
|
||||
}
|
||||
|
||||
fun enrollCourse(video: TrainingVideoSummary) {
|
||||
sdk.userApi.enroll(tokenProvider(), video.videoHash) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
screenState.courseEnrollment[video.videoHash] = true
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
onFailure = {
|
||||
host.onCatalogStatus("选修失败:${it.message}")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRemoteVideo(video: TrainingVideoSummary) {
|
||||
screenState.currentCourse = video
|
||||
screenState.activeItemId = video.videoHash
|
||||
host.onPlayerLoading(video)
|
||||
host.onCatalogChanged()
|
||||
|
||||
val cachedBoundary = screenState.courseBoundaries[video.videoHash]
|
||||
if (cachedBoundary != null) {
|
||||
host.syncSentenceExplanations(cachedBoundary.videoHash, cachedBoundary.sentences)
|
||||
}
|
||||
controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty()))
|
||||
screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount
|
||||
if (cachedBoundary == null) {
|
||||
refreshSentenceBoundaries(video, background = false)
|
||||
} else {
|
||||
host.refreshPlayerUi()
|
||||
host.onStatus("课程已就绪:${cachedBoundary.sentences.size} 句")
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSentenceBoundaries(
|
||||
video: TrainingVideoSummary,
|
||||
background: Boolean,
|
||||
) {
|
||||
val requestGeneration = screenState.beginBoundaryRequest()
|
||||
host.onStatus(
|
||||
if (background) "正在刷新句子边界..." else "正在加载云端课程...",
|
||||
)
|
||||
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
|
||||
override fun onSuccess(result: SentenceBoundaryApiResult) {
|
||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||
return
|
||||
}
|
||||
screenState.courseBoundaries[result.videoHash] = result
|
||||
host.syncSentenceExplanations(result.videoHash, result.sentences)
|
||||
updateCatalogVideo(video.videoHash, result)
|
||||
screenState.currentSentenceCount = result.sentences.size
|
||||
controller.updateSentences(video.videoHash, result.sentences)
|
||||
host.onCatalogChanged()
|
||||
host.refreshPlayerUi()
|
||||
host.onStatus("课程已就绪:${result.sentences.size} 句")
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||
return
|
||||
}
|
||||
host.onStatus("句子边界加载失败:${error.message.orEmpty()}")
|
||||
host.onSentencePlaceholder("暂无句子边界", "")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Persists per-course dubbing audio and assessment scores under app filesDir. */
|
||||
internal class DubbingHistoryStore(context: Context) {
|
||||
private val rootDir = File(context.applicationContext.filesDir, "dubbing-history")
|
||||
|
||||
fun loadSnapshot(videoHash: String): Snapshot {
|
||||
val normalized = normalize(videoHash) ?: return Snapshot()
|
||||
val dir = videoDir(normalized)
|
||||
if (!dir.isDirectory) {
|
||||
return Snapshot()
|
||||
}
|
||||
val segments = dir.listFiles()
|
||||
?.mapNotNull { file ->
|
||||
val match = SEGMENT_PATTERN.matchEntire(file.name) ?: return@mapNotNull null
|
||||
if (!file.isFile || file.length() <= 0L) return@mapNotNull null
|
||||
match.groupValues[1].toInt() to file
|
||||
}
|
||||
?.toMap()
|
||||
.orEmpty()
|
||||
val merged = File(dir, MERGED_FILE_NAME).takeIf { it.isFile && it.length() > 0L }
|
||||
val scores = readScores(File(dir, SCORES_FILE_NAME))
|
||||
return Snapshot(segments = segments, scores = scores, mergedFile = merged)
|
||||
}
|
||||
|
||||
fun saveSegment(videoHash: String, sentenceIndex: Int, source: File): File {
|
||||
val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" }
|
||||
val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a")
|
||||
if (source.canonicalFile != dest.canonicalFile) {
|
||||
source.copyTo(dest, overwrite = true)
|
||||
}
|
||||
// Any new recording invalidates the previous merged export.
|
||||
File(videoDir(normalized), MERGED_FILE_NAME).delete()
|
||||
return dest
|
||||
}
|
||||
|
||||
fun saveScores(videoHash: String, scores: Map<Int, ImitationAssessmentResult>) {
|
||||
val normalized = normalize(videoHash) ?: return
|
||||
val file = File(videoDir(normalized), SCORES_FILE_NAME)
|
||||
val root = JSONObject()
|
||||
scores.toSortedMap().forEach { (index, result) ->
|
||||
root.put(index.toString(), encodeScore(result))
|
||||
}
|
||||
file.writeText(root.toString())
|
||||
}
|
||||
|
||||
fun saveMerged(videoHash: String, source: File): File {
|
||||
val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" }
|
||||
val dest = File(videoDir(normalized), MERGED_FILE_NAME)
|
||||
if (source.canonicalFile != dest.canonicalFile) {
|
||||
source.copyTo(dest, overwrite = true)
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
fun clear(videoHash: String) {
|
||||
val normalized = normalize(videoHash) ?: return
|
||||
videoDir(normalized).deleteRecursively()
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads one public share-audio URL into local history.
|
||||
* Returns the saved file, or null when the download fails.
|
||||
*/
|
||||
fun downloadSegmentAudio(
|
||||
baseUrl: String,
|
||||
videoHash: String,
|
||||
sentenceIndex: Int,
|
||||
audioUrl: String,
|
||||
): File? {
|
||||
val normalized = normalize(videoHash) ?: return null
|
||||
val absolute = when {
|
||||
audioUrl.startsWith("http://") || audioUrl.startsWith("https://") -> audioUrl
|
||||
audioUrl.startsWith("/") -> baseUrl.trimEnd('/') + audioUrl
|
||||
else -> baseUrl.trimEnd('/') + "/" + audioUrl
|
||||
}
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
connection = URL(absolute).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.instanceFollowRedirects = true
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
return null
|
||||
}
|
||||
val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a")
|
||||
connection.inputStream.use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
if (dest.length() <= 0L) {
|
||||
dest.delete()
|
||||
null
|
||||
} else {
|
||||
dest
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoDir(videoHash: String): File =
|
||||
File(rootDir, videoHash).also { it.mkdirs() }
|
||||
|
||||
private fun readScores(file: File): Map<Int, ImitationAssessmentResult> {
|
||||
if (!file.isFile) {
|
||||
return emptyMap()
|
||||
}
|
||||
return runCatching {
|
||||
val root = JSONObject(file.readText())
|
||||
buildMap {
|
||||
for (key in root.keys()) {
|
||||
val index = key.toIntOrNull() ?: continue
|
||||
val value = root.optJSONObject(key) ?: continue
|
||||
put(index, decodeScore(value))
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
data class Snapshot(
|
||||
val segments: Map<Int, File> = emptyMap(),
|
||||
val scores: Map<Int, ImitationAssessmentResult> = emptyMap(),
|
||||
val mergedFile: File? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SCORES_FILE_NAME = "scores.json"
|
||||
private const val MERGED_FILE_NAME = "merged.m4a"
|
||||
private val SEGMENT_PATTERN = Regex("""segment-(\d+)\.m4a""")
|
||||
|
||||
fun normalize(videoHash: String): String? {
|
||||
val value = videoHash.trim().lowercase(Locale.US)
|
||||
return value.takeIf { PlaybackFormat.isVideoHash(it) }
|
||||
}
|
||||
|
||||
fun encodeScore(result: ImitationAssessmentResult): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("overall_score", result.overallScore.toDouble())
|
||||
result.pronunciationScore?.let { put("pronunciation_score", it.toDouble()) }
|
||||
result.fluencyScore?.let { put("fluency_score", it.toDouble()) }
|
||||
result.completenessScore?.let { put("completeness_score", it.toDouble()) }
|
||||
result.feedback?.let { put("feedback", it) }
|
||||
if (result.details.isNotEmpty()) {
|
||||
put(
|
||||
"details",
|
||||
JSONObject().also { details ->
|
||||
result.details.forEach { (key, value) -> details.put(key, value) }
|
||||
},
|
||||
)
|
||||
}
|
||||
result.passed?.let { put("passed", it) }
|
||||
result.contentScore?.let { put("content_score", it.toDouble()) }
|
||||
result.durationScore?.let { put("duration_score", it.toDouble()) }
|
||||
result.pauseScore?.let { put("pause_score", it.toDouble()) }
|
||||
result.speechRateScore?.let { put("speech_rate_score", it.toDouble()) }
|
||||
result.durationRatio?.let { put("duration_ratio", it.toDouble()) }
|
||||
result.referenceText?.let { put("reference_text", it) }
|
||||
result.recognizedText?.let { put("recognized_text", it) }
|
||||
if (result.missingTokens.isNotEmpty()) {
|
||||
put("missing_tokens", JSONArray(result.missingTokens))
|
||||
}
|
||||
if (result.extraTokens.isNotEmpty()) {
|
||||
put("extra_tokens", JSONArray(result.extraTokens))
|
||||
}
|
||||
if (result.substitutions.isNotEmpty()) {
|
||||
put(
|
||||
"substitutions",
|
||||
JSONArray().also { array ->
|
||||
result.substitutions.forEach { (expected, actual) ->
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("expected", expected)
|
||||
.put("actual", actual),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeScore(root: JSONObject): ImitationAssessmentResult {
|
||||
val substitutions = buildList {
|
||||
val array = root.optJSONArray("substitutions") ?: return@buildList
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.optJSONObject(index) ?: continue
|
||||
add(item.optString("expected") to item.optString("actual"))
|
||||
}
|
||||
}
|
||||
val details = buildMap {
|
||||
val objectValue = root.optJSONObject("details") ?: return@buildMap
|
||||
for (key in objectValue.keys()) {
|
||||
put(key, objectValue.optString(key))
|
||||
}
|
||||
}
|
||||
return ImitationAssessmentResult(
|
||||
overallScore = root.optDouble("overall_score", 0.0).toFloat(),
|
||||
pronunciationScore = root.optNullableFloat("pronunciation_score"),
|
||||
fluencyScore = root.optNullableFloat("fluency_score"),
|
||||
completenessScore = root.optNullableFloat("completeness_score"),
|
||||
feedback = root.optString("feedback").ifBlank { null },
|
||||
details = details,
|
||||
passed = if (root.has("passed")) root.optBoolean("passed") else null,
|
||||
contentScore = root.optNullableFloat("content_score"),
|
||||
durationScore = root.optNullableFloat("duration_score"),
|
||||
pauseScore = root.optNullableFloat("pause_score"),
|
||||
speechRateScore = root.optNullableFloat("speech_rate_score"),
|
||||
durationRatio = root.optNullableFloat("duration_ratio"),
|
||||
referenceText = root.optString("reference_text").ifBlank { null },
|
||||
recognizedText = root.optString("recognized_text").ifBlank { null },
|
||||
missingTokens = root.optStringList("missing_tokens"),
|
||||
extraTokens = root.optStringList("extra_tokens"),
|
||||
substitutions = substitutions,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.optNullableFloat(name: String): Float? {
|
||||
if (!has(name) || isNull(name)) return null
|
||||
return optDouble(name).toFloat()
|
||||
}
|
||||
|
||||
private fun JSONObject.optStringList(name: String): List<String> {
|
||||
val array = optJSONArray(name) ?: return emptyList()
|
||||
return buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val value = array.optString(index)
|
||||
if (value.isNotBlank()) add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.max
|
||||
|
||||
internal class DubbingMerger(private val cacheDir: File) {
|
||||
fun merge(segments: List<Pair<Int, File>>): File {
|
||||
require(segments.isNotEmpty()) { "没有可合成的配音" }
|
||||
|
||||
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
|
||||
var muxer: MediaMuxer? = null
|
||||
try {
|
||||
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
var audioTrack = -1
|
||||
var presentationTimeUs = 0L
|
||||
|
||||
segments.forEach { (_, segment) ->
|
||||
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
|
||||
try {
|
||||
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
|
||||
extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)
|
||||
?.startsWith("audio/") == true
|
||||
} ?: throw IllegalStateException("录音没有音频轨道")
|
||||
extractor.selectTrack(trackIndex)
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
if (audioTrack == -1) {
|
||||
audioTrack = muxer.addTrack(format)
|
||||
muxer.start()
|
||||
}
|
||||
val buffer = ByteBuffer.allocateDirect(
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
} else {
|
||||
64 * 1024
|
||||
}
|
||||
)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
info.set(0, sampleSize, presentationTimeUs, extractor.sampleFlags)
|
||||
muxer.writeSampleData(audioTrack, buffer, info)
|
||||
presentationTimeUs += 23_000L
|
||||
extractor.advance()
|
||||
}
|
||||
presentationTimeUs = max(presentationTimeUs, segmentDurationUs(segment))
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
require(audioTrack != -1) { "录音没有可用音频轨道" }
|
||||
muxer.stop()
|
||||
muxer.release()
|
||||
muxer = null
|
||||
return output
|
||||
} catch (error: Throwable) {
|
||||
runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
output.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun segmentDurationUs(segment: File): Long {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
retriever.setDataSource(segment.absolutePath)
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull()?.times(1000L) ?: 0L
|
||||
} catch (error: Throwable) {
|
||||
0L
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import java.io.File
|
||||
|
||||
internal class DubbingPlayback(
|
||||
context: Context,
|
||||
private val mainHandler: Handler = Handler(Looper.getMainLooper()),
|
||||
) {
|
||||
private val player = ExoPlayer.Builder(context).build()
|
||||
private var listener: Player.Listener? = null
|
||||
private var generation = 0
|
||||
|
||||
val isActive: Boolean
|
||||
get() = player.isPlaying || player.playWhenReady
|
||||
|
||||
fun start(
|
||||
audioFile: File,
|
||||
controller: OralTrainerController,
|
||||
sentenceStartMs: Long,
|
||||
sentenceIndex: Int,
|
||||
onComplete: () -> Unit,
|
||||
) {
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
val playbackGeneration = ++generation
|
||||
listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState != Player.STATE_ENDED) return
|
||||
mainHandler.postDelayed({
|
||||
if (generation == playbackGeneration) {
|
||||
stop(controller)
|
||||
onComplete()
|
||||
}
|
||||
}, 250L)
|
||||
}
|
||||
}.also(player::addListener)
|
||||
|
||||
// Mute original audio and drive the video for this sentence only.
|
||||
controller.setVolume(0f)
|
||||
controller.playSentenceAndStop(sentenceStartMs, sentenceIndex)
|
||||
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
|
||||
player.prepare()
|
||||
player.volume = 1f
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
fun stop(controller: OralTrainerController) {
|
||||
generation++
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
controller.pause()
|
||||
controller.setVolume(1f)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
generation++
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.os.StrictMode
|
||||
import java.io.DataOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class DubbingShareApi {
|
||||
fun upload(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
videoHash: String,
|
||||
videoTitle: String,
|
||||
segments: Map<Int, java.io.File>,
|
||||
scores: Map<Int, cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult>,
|
||||
): String {
|
||||
require(token.isNotBlank()) { "Login required." }
|
||||
val previousPolicy = StrictMode.getThreadPolicy()
|
||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
||||
val boundary = "oral-trainer-${System.currentTimeMillis()}"
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection = URL(URL(baseUrl), "/api/v1/dub-shares").openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.setRequestProperty("Authorization", "Bearer $token")
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
|
||||
DataOutputStream(connection.outputStream).use { output ->
|
||||
fun text(name: String, value: String) {
|
||||
output.writeBytes("--$boundary\r\n")
|
||||
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
|
||||
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
|
||||
}
|
||||
text("video_hash", videoHash.lowercase(Locale.US))
|
||||
text("title", videoTitle.ifBlank { "我的口语配音" })
|
||||
text(
|
||||
"segments",
|
||||
segments.keys.sorted().joinToString(",", "[", "]") {
|
||||
"{\"sentence_index\":$it}"
|
||||
},
|
||||
)
|
||||
text(
|
||||
"scores",
|
||||
segments.keys.sorted().mapNotNull { index ->
|
||||
scores[index]?.let { score -> index to score }
|
||||
}.joinToString(",", "[", "]") { (index, score) ->
|
||||
buildString {
|
||||
append("{\"sentence_index\":")
|
||||
append(index)
|
||||
score.overallScore?.let { append(",\"overall_score\":$it") }
|
||||
listOf(
|
||||
"content_score" to score.contentScore,
|
||||
"fluency_score" to score.fluencyScore,
|
||||
"duration_score" to score.durationScore,
|
||||
"pause_score" to score.pauseScore,
|
||||
"speech_rate_score" to score.speechRateScore,
|
||||
).forEach { (name, value) ->
|
||||
value?.let { append(",\"$name\":$it") }
|
||||
}
|
||||
score.recognizedText?.let {
|
||||
append(",\"recognized_text\":\"")
|
||||
append(it.replace("\\", "\\\\").replace("\"", "\\\""))
|
||||
append("\"")
|
||||
}
|
||||
append("}")
|
||||
}
|
||||
},
|
||||
)
|
||||
segments.toSortedMap().forEach { (index, file) ->
|
||||
output.writeBytes("--$boundary\r\n")
|
||||
output.writeBytes(
|
||||
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
|
||||
)
|
||||
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
|
||||
file.inputStream().use { input -> input.copyTo(output) }
|
||||
output.writeBytes("\r\n")
|
||||
}
|
||||
output.writeBytes("--$boundary--\r\n")
|
||||
}
|
||||
|
||||
val code = connection.responseCode
|
||||
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
|
||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
if (code !in 200..299) {
|
||||
throw IllegalStateException(friendlyShareError(code, body))
|
||||
}
|
||||
val response = JSONObject(body).getString("share_id")
|
||||
return "$baseUrl/dub-shares/$response"
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
StrictMode.setThreadPolicy(previousPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun friendlyShareError(code: Int, body: String): String {
|
||||
val detail = runCatching { JSONObject(body).optString("detail") }.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: body
|
||||
return when {
|
||||
code == 401 ||
|
||||
detail.contains("Login required", ignoreCase = true) ||
|
||||
detail.contains("sign in", ignoreCase = true) ||
|
||||
detail.contains("Session expired", ignoreCase = true) ->
|
||||
"还未登录或登录已过期,请重新登录后再分享"
|
||||
detail.isNotBlank() -> "HTTP $code: $detail"
|
||||
else -> "HTTP $code"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class DubbingState {
|
||||
val segments = mutableMapOf<Int, File>()
|
||||
var mergedFile: File? = null
|
||||
private set
|
||||
|
||||
fun storeSegment(sentenceIndex: Int, file: File) {
|
||||
val previous = segments.put(sentenceIndex, file)
|
||||
if (previous != null && previous.absolutePath != file.absolutePath) {
|
||||
previous.delete()
|
||||
}
|
||||
mergedFile = null
|
||||
}
|
||||
|
||||
fun setMergedFile(file: File?) {
|
||||
mergedFile = file
|
||||
}
|
||||
|
||||
fun replaceAll(newSegments: Map<Int, File>, merged: File?) {
|
||||
val keep = newSegments.values.map { it.absolutePath }.toHashSet()
|
||||
segments.values
|
||||
.filter { it.absolutePath !in keep }
|
||||
.forEach { /* keep history files on disk; only drop memory refs */ }
|
||||
segments.clear()
|
||||
segments.putAll(newSegments)
|
||||
mergedFile = merged?.takeIf { it.exists() }
|
||||
}
|
||||
|
||||
fun clearMemory() {
|
||||
segments.clear()
|
||||
mergedFile = null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.widget.TextView
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
/** Renders common LLM Markdown into a TextView without extra libraries. */
|
||||
internal object MarkdownText {
|
||||
private val heading = Regex("^(#{1,6})\\s*(.*)$")
|
||||
private val unordered = Regex("^[-*+]\\s+(.*)$")
|
||||
private val ordered = Regex("^\\d+[.)]\\s+(.*)$")
|
||||
private val boldItalic = Regex("\\*\\*\\*(.+?)\\*\\*\\*")
|
||||
private val bold = Regex("(\\*\\*|__)(.+?)\\1")
|
||||
private val italic = Regex("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)")
|
||||
private val italicUnderscore = Regex("(?<!\\w)_(?!_)(.+?)(?<!_)_(?!\\w)")
|
||||
private val inlineCode = Regex("`([^`]+)`")
|
||||
private val image = Regex("!\\[([^\\]]*)]\\(([^)]+)\\)")
|
||||
private val link = Regex("\\[([^\\]]+)]\\(([^)]+)\\)")
|
||||
|
||||
fun set(view: TextView, markdown: String) {
|
||||
view.text = render(markdown)
|
||||
}
|
||||
|
||||
fun render(markdown: String): CharSequence {
|
||||
return HtmlCompat.fromHtml(toHtml(markdown), HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
}
|
||||
|
||||
internal fun toHtml(markdown: String): String {
|
||||
val normalized = markdown.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val html = StringBuilder()
|
||||
var inFence = false
|
||||
val code = StringBuilder()
|
||||
var listTag: String? = null
|
||||
|
||||
fun closeList() {
|
||||
listTag?.let { html.append("</$it>") }
|
||||
listTag = null
|
||||
}
|
||||
|
||||
fun openList(tag: String) {
|
||||
if (listTag != tag) {
|
||||
closeList()
|
||||
listTag = tag
|
||||
html.append('<').append(tag).append('>')
|
||||
}
|
||||
}
|
||||
|
||||
for (line in normalized.split('\n')) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith("```")) {
|
||||
if (inFence) {
|
||||
html.append("<pre><code>").append(code).append("</code></pre>")
|
||||
code.setLength(0)
|
||||
inFence = false
|
||||
} else {
|
||||
closeList()
|
||||
inFence = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (inFence) {
|
||||
if (code.isNotEmpty()) code.append('\n')
|
||||
code.append(escape(line))
|
||||
continue
|
||||
}
|
||||
if (trimmed.isEmpty()) {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
|
||||
val headingMatch = heading.matchEntire(trimmed)
|
||||
if (headingMatch != null) {
|
||||
closeList()
|
||||
val level = headingMatch.groupValues[1].length
|
||||
html.append("<h$level>").append(inline(headingMatch.groupValues[2])).append("</h$level>")
|
||||
continue
|
||||
}
|
||||
val unorderedMatch = unordered.matchEntire(trimmed)
|
||||
if (unorderedMatch != null) {
|
||||
openList("ul")
|
||||
html.append("<li>").append(inline(unorderedMatch.groupValues[1])).append("</li>")
|
||||
continue
|
||||
}
|
||||
val orderedMatch = ordered.matchEntire(trimmed)
|
||||
if (orderedMatch != null) {
|
||||
openList("ol")
|
||||
html.append("<li>").append(inline(orderedMatch.groupValues[1])).append("</li>")
|
||||
continue
|
||||
}
|
||||
closeList()
|
||||
if (trimmed.startsWith(">")) {
|
||||
html.append("<blockquote>")
|
||||
.append(inline(trimmed.removePrefix(">").trimStart()))
|
||||
.append("</blockquote>")
|
||||
} else {
|
||||
html.append("<p>").append(inline(trimmed)).append("</p>")
|
||||
}
|
||||
}
|
||||
if (inFence) {
|
||||
html.append("<pre><code>").append(code).append("</code></pre>")
|
||||
}
|
||||
closeList()
|
||||
return html.toString()
|
||||
}
|
||||
|
||||
private fun inline(text: String): String {
|
||||
val codes = mutableListOf<String>()
|
||||
var current = escape(text)
|
||||
current = inlineCode.replace(current) { match ->
|
||||
codes += "<code>${match.groupValues[1]}</code>"
|
||||
"%%MDCODE${codes.lastIndex}%%"
|
||||
}
|
||||
current = image.replace(current) { match -> escape(match.groupValues[1]) }
|
||||
current = link.replace(current) { match ->
|
||||
"""<a href="${match.groupValues[2]}">${match.groupValues[1]}</a>"""
|
||||
}
|
||||
current = boldItalic.replace(current) { match -> "<b><i>${match.groupValues[1]}</i></b>" }
|
||||
current = bold.replace(current) { match -> "<b>${match.groupValues[2]}</b>" }
|
||||
current = italic.replace(current) { match -> "<i>${match.groupValues[1]}</i>" }
|
||||
current = italicUnderscore.replace(current) { match -> "<i>${match.groupValues[1]}</i>" }
|
||||
codes.forEachIndexed { index, value ->
|
||||
current = current.replace("%%MDCODE$index%%", value)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun escape(text: String): String =
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
|
||||
import java.util.Locale
|
||||
|
||||
internal object PlaybackFormat {
|
||||
fun status(snapshot: PlaybackSnapshot): String {
|
||||
val state = when {
|
||||
snapshot.isPlaying -> "播放中"
|
||||
snapshot.playbackState.name == "BUFFERING" -> "缓冲中"
|
||||
snapshot.playbackState.name == "ENDED" -> "已结束"
|
||||
else -> "已暂停"
|
||||
}
|
||||
val sentence = snapshot.sentenceIndex?.let { " · 第 ${it + 1} 句" }.orEmpty()
|
||||
return "$state$sentence"
|
||||
}
|
||||
|
||||
fun progress(snapshot: PlaybackSnapshot, max: Int = PROGRESS_MAX): Int {
|
||||
if (snapshot.durationMs <= 0L) {
|
||||
return 0
|
||||
}
|
||||
return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * max) / snapshot.durationMs).toInt()
|
||||
}
|
||||
|
||||
fun seekPosition(progress: Int, durationMs: Long, max: Int = PROGRESS_MAX): Long {
|
||||
if (durationMs <= 0L) {
|
||||
return 0L
|
||||
}
|
||||
return progress.toLong() * durationMs / max
|
||||
}
|
||||
|
||||
fun time(ms: Long): String {
|
||||
if (ms < 0) {
|
||||
return "--:--"
|
||||
}
|
||||
val totalSeconds = ms / 1000
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (hours > 0) {
|
||||
"%d:%02d:%02d".format(Locale.US, hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(Locale.US, minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun speed(speed: Float): String {
|
||||
val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.')
|
||||
return "${value}x"
|
||||
}
|
||||
|
||||
fun isVideoHash(value: String): Boolean = SHA256_PATTERN.matches(value)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
|
||||
/** Holds view references recreated when the sample app rebuilds its content tree. */
|
||||
internal class SampleBindings {
|
||||
lateinit var playerView: OralTrainerPlayerView
|
||||
lateinit var lessonTitleText: TextView
|
||||
lateinit var statusText: TextView
|
||||
lateinit var sentenceText: TextView
|
||||
lateinit var sentenceZhText: TextView
|
||||
lateinit var sentenceMetaText: TextView
|
||||
lateinit var timeText: TextView
|
||||
lateinit var speedText: TextView
|
||||
lateinit var progressBar: SeekBar
|
||||
lateinit var catalogList: LinearLayout
|
||||
lateinit var catalogStatusText: TextView
|
||||
var catalogRefreshButton: Button? = null
|
||||
lateinit var testStatusText: TextView
|
||||
lateinit var scoreSummaryText: TextView
|
||||
lateinit var scoreDetailText: TextView
|
||||
lateinit var recordButton: Button
|
||||
lateinit var playDubButton: Button
|
||||
lateinit var mergeDubButton: Button
|
||||
lateinit var shareDubButton: Button
|
||||
var dubbingStatusText: TextView? = null
|
||||
var explainSentenceButton: ImageButton? = null
|
||||
var myContentList: LinearLayout? = null
|
||||
var favoriteSentenceList: LinearLayout? = null
|
||||
|
||||
val hasPlayerChrome: Boolean
|
||||
get() = ::lessonTitleText.isInitialized &&
|
||||
::statusText.isInitialized &&
|
||||
::sentenceText.isInitialized &&
|
||||
::sentenceZhText.isInitialized &&
|
||||
::sentenceMetaText.isInitialized &&
|
||||
::timeText.isInitialized &&
|
||||
::speedText.isInitialized &&
|
||||
::progressBar.isInitialized
|
||||
|
||||
val hasCatalogChrome: Boolean
|
||||
get() = ::catalogList.isInitialized && ::catalogStatusText.isInitialized
|
||||
|
||||
val hasTestChrome: Boolean
|
||||
get() = ::testStatusText.isInitialized &&
|
||||
::scoreSummaryText.isInitialized &&
|
||||
::scoreDetailText.isInitialized &&
|
||||
::recordButton.isInitialized &&
|
||||
::playDubButton.isInitialized &&
|
||||
::mergeDubButton.isInitialized &&
|
||||
::shareDubButton.isInitialized
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
internal interface ModuleTabActions {
|
||||
fun onModuleSelected(module: Module)
|
||||
}
|
||||
|
||||
internal interface CourseScreenActions {
|
||||
fun onSelectCourse(video: TrainingVideoSummary)
|
||||
fun onRefreshCourses()
|
||||
}
|
||||
|
||||
internal interface SentenceExplainActions {
|
||||
fun onExplainCurrentSentence()
|
||||
}
|
||||
|
||||
internal interface TrainScreenActions :
|
||||
SentenceExplainActions {
|
||||
fun onSeekProgressPreview(progress: Int)
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onContinuousPlaybackChanged(enabled: Boolean)
|
||||
fun onChineseSubtitlesChanged(enabled: Boolean)
|
||||
}
|
||||
|
||||
internal interface ReadingScreenActions {
|
||||
fun onReadingTranslationVisible(visible: Boolean)
|
||||
fun isSentenceFavorite(sentence: SentenceBoundary): Boolean
|
||||
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
|
||||
fun onSentenceDoubleTapped(sentence: SentenceBoundary)
|
||||
}
|
||||
|
||||
internal interface TestScreenActions :
|
||||
SentenceExplainActions {
|
||||
fun onSeekProgressPreview(progress: Int)
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onTestSubtitleChanged(visible: Boolean)
|
||||
fun onPrevSentence()
|
||||
fun onReplaySentence()
|
||||
fun onNextSentence()
|
||||
fun onToggleRecording()
|
||||
fun onToggleDubPlayback()
|
||||
fun onMergeDub()
|
||||
fun onShareDub()
|
||||
}
|
||||
|
||||
internal interface MineScreenActions {
|
||||
fun onLogout()
|
||||
fun nickname(): String
|
||||
fun favoriteSentences(): List<SentenceBoundary>
|
||||
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
|
||||
}
|
||||
|
||||
internal interface SampleScreenActions :
|
||||
ModuleTabActions,
|
||||
CourseScreenActions,
|
||||
TrainScreenActions,
|
||||
ReadingScreenActions,
|
||||
TestScreenActions,
|
||||
MineScreenActions
|
||||
@@ -0,0 +1,113 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import cn.learningpad.oraltrainer.sample.ui.CourseScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.MineScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.PlayerChrome
|
||||
import cn.learningpad.oraltrainer.sample.ui.ReadingScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.SampleWidgets
|
||||
import cn.learningpad.oraltrainer.sample.ui.TestScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.TrainScreen
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
/** Facade that routes content creation to module-specific screens. */
|
||||
internal class SampleScreenFactory(
|
||||
private val activity: Activity,
|
||||
bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
controller: OralTrainerController,
|
||||
private val actions: SampleScreenActions,
|
||||
) {
|
||||
private val widgets = SampleWidgets(activity, screenState)
|
||||
private val playerChrome = PlayerChrome(
|
||||
activity = activity,
|
||||
bindings = bindings,
|
||||
screenState = screenState,
|
||||
controller = controller,
|
||||
widgets = widgets,
|
||||
seekActions = actions,
|
||||
)
|
||||
private val courseScreen = CourseScreen(activity, bindings, screenState, widgets, actions)
|
||||
private val trainScreen = TrainScreen(
|
||||
activity = activity,
|
||||
playerChrome = playerChrome,
|
||||
)
|
||||
private val testScreen = TestScreen(activity, bindings, screenState, playerChrome, actions)
|
||||
private val readingScreen = ReadingScreen(
|
||||
activity = activity,
|
||||
screenState = screenState,
|
||||
controller = controller,
|
||||
widgets = widgets,
|
||||
actions = actions,
|
||||
)
|
||||
private val mineScreen = MineScreen(activity, bindings, screenState, widgets, actions)
|
||||
|
||||
fun createContentView(): View {
|
||||
val isLandscape =
|
||||
activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
if ((screenState.activeModule == Module.TRAIN || screenState.activeModule == Module.TEST) &&
|
||||
isLandscape
|
||||
) {
|
||||
return playerChrome.createLandscapeContentView()
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(widgets.moduleTabBar(actions))
|
||||
when (screenState.activeModule) {
|
||||
Module.COURSE -> addView(courseScreen.createSection())
|
||||
Module.MINE -> addView(mineScreen.createSection())
|
||||
Module.TRAIN -> trainScreen.attachPortraitSections(this)
|
||||
Module.READING -> addView(readingScreen.createSection())
|
||||
Module.TEST -> testScreen.attachSections(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshReadingSentences() {
|
||||
if (screenState.activeModule == Module.READING) {
|
||||
readingScreen.renderSentences()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshReadingTranslations() {
|
||||
if (screenState.activeModule == Module.READING) {
|
||||
readingScreen.refreshTranslations()
|
||||
}
|
||||
}
|
||||
|
||||
fun scrollToReadingCurrentSentence() {
|
||||
if (screenState.activeModule == Module.READING) {
|
||||
readingScreen.scrollToCurrentSentence()
|
||||
}
|
||||
}
|
||||
|
||||
fun renderCatalog() {
|
||||
if (screenState.activeModule == Module.COURSE) {
|
||||
courseScreen.renderCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
fun showCatalogLoading() {
|
||||
if (screenState.activeModule == Module.COURSE) {
|
||||
courseScreen.showLoading()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCatalogStatus(text: String) {
|
||||
courseScreen.setStatus(text)
|
||||
}
|
||||
|
||||
fun refreshMyContent(userApi: UserApi, token: String) {
|
||||
mineScreen.refreshContent(userApi, token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.graphics.Color
|
||||
|
||||
internal object SampleTheme {
|
||||
val background = Color.rgb(12, 15, 18)
|
||||
val surface = Color.rgb(28, 34, 40)
|
||||
val border = Color.rgb(50, 59, 67)
|
||||
val button = Color.rgb(37, 99, 235)
|
||||
val accent = Color.rgb(21, 184, 132)
|
||||
val accentDeep = Color.rgb(7, 118, 86)
|
||||
val accentLight = Color.rgb(94, 234, 182)
|
||||
val selected = Color.rgb(229, 248, 240)
|
||||
val progressTrack = Color.rgb(224, 231, 235)
|
||||
val progressTrackDark = Color.rgb(55, 62, 70)
|
||||
val lightBorder = Color.rgb(218, 226, 232)
|
||||
val overlay = Color.argb(178, 9, 12, 16)
|
||||
val overlayFaint = Color.argb(51, 9, 12, 16)
|
||||
val textDark = Color.rgb(18, 24, 31)
|
||||
val textMuted = Color.rgb(151, 162, 174)
|
||||
val textSubtle = Color.rgb(88, 98, 108)
|
||||
val readingTranslation = Color.rgb(147, 197, 253)
|
||||
val danger = Color.rgb(220, 38, 38)
|
||||
val textOverlay = Color.rgb(163, 172, 182)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.LoopMode
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
|
||||
import cn.learningpad.oraltrainer.sdk.PlayerConfig
|
||||
|
||||
internal data class SdkComponents(
|
||||
val sdk: OralTrainerSdk,
|
||||
val controller: OralTrainerController,
|
||||
)
|
||||
|
||||
internal object SdkModule {
|
||||
fun create(context: Context, userToken: String?): SdkComponents {
|
||||
val sdk = OralTrainerSdk.init(
|
||||
context,
|
||||
OralTrainerSdkConfig(
|
||||
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
|
||||
assessmentApiKey = ASSESSMENT_API_KEY,
|
||||
userToken = userToken?.takeIf { it.isNotBlank() },
|
||||
allowHttpFallback = true,
|
||||
),
|
||||
)
|
||||
val controller = sdk.createController(
|
||||
playerConfig = PlayerConfig(
|
||||
sentenceMode = true,
|
||||
defaultSeekStepMs = 10_000L,
|
||||
autoPlay = false,
|
||||
continuousPlayback = false,
|
||||
),
|
||||
imitationAssessor = sdk.createRemoteImitationQualityAssessor(),
|
||||
).also {
|
||||
it.setLoopMode(LoopMode.ALL)
|
||||
}
|
||||
return SdkComponents(sdk, controller)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class SentenceExplanationResult(
|
||||
val explanation: String,
|
||||
)
|
||||
|
||||
interface SentenceExplanationCallback {
|
||||
fun onSuccess(result: SentenceExplanationResult)
|
||||
fun onError(error: Throwable)
|
||||
}
|
||||
|
||||
class CancellableExplanationRequest internal constructor(
|
||||
private val future: Future<*>,
|
||||
) {
|
||||
fun cancel() {
|
||||
future.cancel(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the app server; AI credentials never live in the mobile app. */
|
||||
internal class SentenceExplanationApi(
|
||||
context: Context,
|
||||
private val baseUrl: String,
|
||||
private val tokenProvider: () -> String,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
|
||||
fun explain(
|
||||
sentenceText: String,
|
||||
courseTitle: String,
|
||||
sentenceIndex: Int?,
|
||||
language: String?,
|
||||
callback: SentenceExplanationCallback,
|
||||
): CancellableExplanationRequest {
|
||||
val future = executor.submit {
|
||||
try {
|
||||
val result = requestBlocking(
|
||||
sentenceText,
|
||||
courseTitle,
|
||||
sentenceIndex,
|
||||
language,
|
||||
)
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onSuccess(result) }
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onError(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return CancellableExplanationRequest(future)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun requestBlocking(
|
||||
sentenceText: String,
|
||||
courseTitle: String,
|
||||
sentenceIndex: Int?,
|
||||
language: String?,
|
||||
): SentenceExplanationResult {
|
||||
val endpoint = baseUrl.trimEnd('/') + "/api/v1/sentences/explain"
|
||||
val body = JSONObject().apply {
|
||||
put("sentence_text", sentenceText)
|
||||
put("course_title", courseTitle)
|
||||
sentenceIndex?.let { put("sentence_index", it) }
|
||||
language?.takeIf { it.isNotBlank() }?.let { put("language", it) }
|
||||
}
|
||||
val connection = URL(endpoint).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.doOutput = true
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.5 (Android)")
|
||||
tokenProvider().takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
connection.outputStream.use { output ->
|
||||
output.write(body.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw IOException("AI explain API returned HTTP $statusCode: $response")
|
||||
}
|
||||
val explanation = JSONObject(response).getString("explanation")
|
||||
return SentenceExplanationResult(explanation)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
|
||||
/** Modal explanation layer shown after the user taps the subtitle AI button. */
|
||||
internal class SentenceExplanationOverlay(
|
||||
private val activity: Activity,
|
||||
private val api: SentenceExplanationApi,
|
||||
) {
|
||||
private val store = SentenceExplanationStore(activity)
|
||||
private var currentOverlay: View? = null
|
||||
private var currentRequest: CancellableExplanationRequest? = null
|
||||
private var requestGeneration = 0
|
||||
|
||||
fun show(
|
||||
sentence: SentenceBoundary,
|
||||
courseTitle: String,
|
||||
mediaId: String,
|
||||
sentences: List<SentenceBoundary> = emptyList(),
|
||||
) {
|
||||
if (sentences.isNotEmpty()) {
|
||||
store.sync(mediaId, sentences)
|
||||
}
|
||||
close()
|
||||
val generation = ++requestGeneration
|
||||
val root = FrameLayout(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(Color.argb(160, 0, 0, 0))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
val card = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(Color.WHITE, 16f, SampleTheme.lightBorder)
|
||||
setPadding(20.dp, 20.dp, 20.dp, 18.dp)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
Gravity.CENTER,
|
||||
).apply {
|
||||
setMargins(24.dp, 48.dp, 24.dp, 48.dp)
|
||||
}
|
||||
}
|
||||
val title = TextView(activity).apply {
|
||||
text = "句子讲解"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 19f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val saveHint = TextView(activity).apply {
|
||||
text = HINT_UNSAVED
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 12f
|
||||
setPadding(6.dp, 0, 0, 0)
|
||||
}
|
||||
val saveButton = TextView(activity).apply {
|
||||
text = "保存"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
setPadding(12.dp, 6.dp, 12.dp, 6.dp)
|
||||
isEnabled = false
|
||||
alpha = 0.45f
|
||||
}
|
||||
val titleRow = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
addView(title)
|
||||
addView(saveButton)
|
||||
addView(saveHint)
|
||||
}
|
||||
val subtitle = TextView(activity).apply {
|
||||
text = sentence.text?.takeIf { it.isNotBlank() } ?: courseTitle
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 14f
|
||||
setPadding(0, 8.dp, 0, 12.dp)
|
||||
maxLines = 5
|
||||
}
|
||||
val message = TextView(activity).apply {
|
||||
text = "AI 正在讲解这句话…"
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 15f
|
||||
setLineSpacing(3.dp.toFloat(), 1.1f)
|
||||
}
|
||||
val scroll = ScrollView(activity).apply {
|
||||
addView(message)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
val close = TextView(activity).apply {
|
||||
text = "关闭"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
background = rounded(SampleTheme.button, 10f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).apply { topMargin = 16.dp }
|
||||
setOnClickListener { close() }
|
||||
}
|
||||
card.addView(titleRow)
|
||||
card.addView(subtitle)
|
||||
card.addView(scroll)
|
||||
card.addView(close)
|
||||
root.addView(card)
|
||||
|
||||
addToWindow(root)
|
||||
currentOverlay = root
|
||||
|
||||
val cached = store.load(mediaId, sentence.index)
|
||||
if (!cached.isNullOrBlank()) {
|
||||
showMarkdown(message, cached)
|
||||
markSaved(saveButton, saveHint)
|
||||
return
|
||||
}
|
||||
|
||||
var loadedExplanation: String? = null
|
||||
saveButton.setOnClickListener {
|
||||
val explanation = loadedExplanation ?: return@setOnClickListener
|
||||
if (store.save(mediaId, sentence.index, explanation)) {
|
||||
markSaved(saveButton, saveHint)
|
||||
}
|
||||
}
|
||||
currentRequest = api.explain(
|
||||
sentence.text.orEmpty(),
|
||||
courseTitle,
|
||||
sentence.index,
|
||||
sentence.language,
|
||||
object : SentenceExplanationCallback {
|
||||
override fun onSuccess(result: SentenceExplanationResult) {
|
||||
if (generation != requestGeneration) return
|
||||
loadedExplanation = result.explanation
|
||||
showMarkdown(message, result.explanation)
|
||||
saveButton.isEnabled = true
|
||||
saveButton.alpha = 1f
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (generation != requestGeneration) return
|
||||
message.text = "讲解失败:${error.message ?: "请稍后重试"}"
|
||||
message.setTextColor(SampleTheme.danger)
|
||||
saveButton.isEnabled = false
|
||||
saveButton.alpha = 0.45f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
currentRequest?.cancel()
|
||||
currentRequest = null
|
||||
currentOverlay?.let { overlay ->
|
||||
(overlay.parent as? ViewGroup)?.removeView(overlay)
|
||||
}
|
||||
currentOverlay = null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
close()
|
||||
api.release()
|
||||
}
|
||||
|
||||
private fun showMarkdown(message: TextView, explanation: String) {
|
||||
message.setTextColor(SampleTheme.textDark)
|
||||
MarkdownText.set(message, explanation)
|
||||
}
|
||||
|
||||
private fun markSaved(saveButton: TextView, saveHint: TextView) {
|
||||
saveButton.text = "已保存"
|
||||
saveButton.isEnabled = false
|
||||
saveButton.alpha = 0.7f
|
||||
saveHint.text = HINT_SAVED
|
||||
}
|
||||
|
||||
private fun addToWindow(view: View) {
|
||||
(activity.window?.decorView as? ViewGroup)?.addView(view)
|
||||
}
|
||||
|
||||
private val Int.dp: Int
|
||||
get() = (this * activity.resources.displayMetrics.density).toInt()
|
||||
|
||||
private val Float.dp: Int
|
||||
get() = (this * activity.resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun rounded(
|
||||
color: Int,
|
||||
radiusDp: Float,
|
||||
strokeColor: Int? = null,
|
||||
): GradientDrawable = GradientDrawable().apply {
|
||||
setColor(color)
|
||||
cornerRadius = radiusDp.dp.toFloat()
|
||||
strokeColor?.let { setStroke(1.dp, it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HINT_UNSAVED = "(下次直接看本地)"
|
||||
const val HINT_SAVED = "(已保存本地)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/** Persists sentence explanations under app filesDir for reuse without another API call. */
|
||||
internal class SentenceExplanationStore(context: Context) {
|
||||
private val rootDir = File(context.applicationContext.filesDir, "sentence-explanations")
|
||||
|
||||
fun load(mediaId: String, sentenceIndex: Int): String? {
|
||||
val file = fileFor(mediaId, sentenceIndex) ?: return null
|
||||
if (!file.isFile || file.length() <= 0L) return null
|
||||
return file.readText()
|
||||
}
|
||||
|
||||
fun save(mediaId: String, sentenceIndex: Int, explanation: String): Boolean {
|
||||
val text = explanation.trim()
|
||||
if (text.isEmpty()) return false
|
||||
val file = fileFor(mediaId, sentenceIndex) ?: return false
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(text)
|
||||
return file.isFile && file.length() > 0L
|
||||
}
|
||||
|
||||
fun sync(mediaId: String, sentences: List<SentenceBoundary>) {
|
||||
if (sentences.isEmpty()) return
|
||||
val directoryName = sanitize(mediaId) ?: return
|
||||
val dir = File(rootDir, directoryName)
|
||||
val revision = revisionFor(sentences)
|
||||
val marker = File(dir, REVISION_FILE)
|
||||
val previous = marker.takeIf { it.isFile }?.readText()?.trim().orEmpty()
|
||||
if (previous == revision) return
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
dir.mkdirs()
|
||||
marker.writeText(revision)
|
||||
}
|
||||
|
||||
private fun fileFor(mediaId: String, sentenceIndex: Int): File? {
|
||||
val directoryName = sanitize(mediaId) ?: return null
|
||||
return File(File(rootDir, directoryName), "$sentenceIndex.txt")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REVISION_FILE = "revision.txt"
|
||||
|
||||
fun revisionFor(sentences: List<SentenceBoundary>): String =
|
||||
sentences.joinToString("\n") { sentence ->
|
||||
"${sentence.index}\t${sentence.text.orEmpty().trim()}"
|
||||
}
|
||||
|
||||
fun sanitize(mediaId: String): String? {
|
||||
val filtered = mediaId.trim().lowercase(Locale.US)
|
||||
.replace(Regex("[^a-z0-9._-]"), "_")
|
||||
.trim('_')
|
||||
return filtered.takeIf { it.isNotBlank() && it != "." && it != ".." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Persists reading-mode favorites locally, scoped by signed-in user and video. */
|
||||
internal class SentenceFavoriteStore(context: Context) {
|
||||
private val preferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun toggle(userKey: String, mediaId: String, sentenceIndex: Int): Boolean {
|
||||
val key = storageKey(userKey, mediaId) ?: return false
|
||||
val favorites = readIndexes(key)
|
||||
val isFavorite = sentenceIndex in favorites
|
||||
val updated = if (isFavorite) {
|
||||
favorites - sentenceIndex
|
||||
} else {
|
||||
favorites + sentenceIndex
|
||||
}
|
||||
preferences.edit()
|
||||
.putString(key, updated.sorted().joinToString(SEPARATOR))
|
||||
.apply()
|
||||
return !isFavorite
|
||||
}
|
||||
|
||||
fun indexes(userKey: String, mediaId: String): Set<Int> {
|
||||
return storageKey(userKey, mediaId)?.let(::readIndexes).orEmpty()
|
||||
}
|
||||
|
||||
private fun readIndexes(key: String): Set<Int> {
|
||||
return preferences.getString(key, null).orEmpty()
|
||||
.split(SEPARATOR)
|
||||
.mapNotNull { it.trim().toIntOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun storageKey(userKey: String, mediaId: String): String? {
|
||||
val normalizedUser = userKey.trim()
|
||||
val normalizedMedia = mediaId.trim()
|
||||
if (normalizedUser.isEmpty() || normalizedMedia.isEmpty()) return null
|
||||
return "$KEY_PREFIX$normalizedUser:$normalizedMedia"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES_NAME = "sentence_reading_favorites"
|
||||
private const val KEY_PREFIX = "indexes"
|
||||
private const val SEPARATOR = ","
|
||||
|
||||
fun userKey(token: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(token.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }.take(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class SubtitleTranslationResult(
|
||||
val videoHash: String,
|
||||
val translations: Map<Int, String>,
|
||||
val complete: Boolean = false,
|
||||
val status: String = "",
|
||||
val error: String = "",
|
||||
)
|
||||
|
||||
interface SubtitleTranslationCallback {
|
||||
fun onProgress(result: SubtitleTranslationResult) {}
|
||||
fun onSuccess(result: SubtitleTranslationResult)
|
||||
fun onError(error: Throwable)
|
||||
}
|
||||
|
||||
class CancellableTranslationRequest internal constructor(
|
||||
private val future: Future<*>,
|
||||
) {
|
||||
fun cancel() {
|
||||
future.cancel(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the app server; AI credentials never live in the mobile app. */
|
||||
internal class SubtitleTranslationApi(
|
||||
context: Context,
|
||||
private val baseUrl: String,
|
||||
private val tokenProvider: () -> String,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
|
||||
fun translate(
|
||||
videoHash: String,
|
||||
expectedIndexes: Collection<Int> = emptyList(),
|
||||
callback: SubtitleTranslationCallback,
|
||||
): CancellableTranslationRequest {
|
||||
val future = executor.submit {
|
||||
try {
|
||||
val result = loadBlocking(videoHash, expectedIndexes) { partial ->
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onProgress(partial) }
|
||||
}
|
||||
}
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onSuccess(result) }
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onError(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return CancellableTranslationRequest(future)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun loadBlocking(
|
||||
videoHash: String,
|
||||
expectedIndexes: Collection<Int>,
|
||||
onProgress: (SubtitleTranslationResult) -> Unit,
|
||||
): SubtitleTranslationResult {
|
||||
val expected = expectedIndexes.toSet()
|
||||
val deadline = SystemClock.elapsedRealtime() + TRANSLATION_DEADLINE_MS
|
||||
var kickJob = true
|
||||
var lastError: Throwable? = null
|
||||
var lastCount = -1
|
||||
var stalledRounds = 0
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
if (Thread.currentThread().isInterrupted) {
|
||||
throw InterruptedException("Subtitle translation cancelled")
|
||||
}
|
||||
val stored = runCatching {
|
||||
requestBlocking(videoHash, method = "GET", readTimeoutMs = 15_000)
|
||||
}.onFailure { lastError = it }.getOrNull()
|
||||
if (stored == null) {
|
||||
kickJob = true
|
||||
} else {
|
||||
onProgress(stored)
|
||||
if (isComplete(stored, expected)) {
|
||||
return stored
|
||||
}
|
||||
if (stored.translations.size > lastCount) {
|
||||
lastCount = stored.translations.size
|
||||
stalledRounds = 0
|
||||
} else {
|
||||
stalledRounds += 1
|
||||
}
|
||||
if (stored.status != "running" && stalledRounds >= 2) {
|
||||
kickJob = true
|
||||
}
|
||||
}
|
||||
if (kickJob) {
|
||||
val posted = runCatching {
|
||||
requestBlocking(videoHash, method = "POST", readTimeoutMs = 30_000)
|
||||
}.onFailure { error ->
|
||||
lastError = error
|
||||
if (error !is SocketTimeoutException) {
|
||||
stalledRounds += 1
|
||||
}
|
||||
}.getOrNull()
|
||||
kickJob = false
|
||||
if (posted != null) {
|
||||
onProgress(posted)
|
||||
if (isComplete(posted, expected)) {
|
||||
return posted
|
||||
}
|
||||
if (posted.status == "error") {
|
||||
lastError = IOException(posted.error.ifBlank { "Subtitle translation failed" })
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
throw lastError ?: IOException("Subtitle translation timed out")
|
||||
}
|
||||
|
||||
private fun isComplete(
|
||||
result: SubtitleTranslationResult,
|
||||
expected: Set<Int>,
|
||||
): Boolean {
|
||||
if (expected.isNotEmpty()) {
|
||||
return expected.all { result.translations.containsKey(it) }
|
||||
}
|
||||
return result.complete
|
||||
}
|
||||
|
||||
private fun requestBlocking(
|
||||
videoHash: String,
|
||||
method: String,
|
||||
readTimeoutMs: Int,
|
||||
): SubtitleTranslationResult {
|
||||
val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation"
|
||||
val connection = URL(endpoint).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = readTimeoutMs
|
||||
connection.doOutput = false
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.5 (Android)")
|
||||
tokenProvider().takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw IOException("Subtitle translation API returned HTTP $statusCode: $response")
|
||||
}
|
||||
val root = JSONObject(response)
|
||||
val items = root.getJSONArray("translations")
|
||||
val translations = buildMap {
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
val zh = item.optString("zh_text")
|
||||
if (zh.isNotBlank()) {
|
||||
put(item.getInt("index"), zh)
|
||||
}
|
||||
}
|
||||
}
|
||||
return SubtitleTranslationResult(
|
||||
videoHash = root.getString("video_hash"),
|
||||
translations = translations,
|
||||
complete = root.optBoolean("complete", false),
|
||||
status = root.optString("status"),
|
||||
error = root.optString("error"),
|
||||
)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_INTERVAL_MS = 1_500L
|
||||
const val TRANSLATION_DEADLINE_MS = 15 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/** Recording, assessment, and dubbing flow for the TEST module. */
|
||||
internal class TestSessionController(
|
||||
private val activity: Activity,
|
||||
private val controller: OralTrainerController,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val attemptRecorder: AttemptRecorder,
|
||||
private val dubbingState: DubbingState,
|
||||
private val dubbingMerger: DubbingMerger,
|
||||
private val dubbingShareApi: DubbingShareApi,
|
||||
private val dubbingPlayback: DubbingPlayback,
|
||||
private val assessmentState: AssessmentState,
|
||||
private val historyStore: DubbingHistoryStore,
|
||||
private val userApi: UserApi,
|
||||
private val tokenProvider: () -> String,
|
||||
private val host: Host,
|
||||
) {
|
||||
interface Host {
|
||||
val bindings: SampleBindings
|
||||
fun setMainStatus(text: String)
|
||||
fun rebuildContent()
|
||||
fun refreshPlayerUi()
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val background = Executors.newSingleThreadExecutor()
|
||||
private val remoteLoadGeneration = AtomicInteger(0)
|
||||
|
||||
private var loadedVideoHash: String? = null
|
||||
private var assessing = false
|
||||
private var sharing = false
|
||||
private var historyStatusMessage: String? = null
|
||||
|
||||
val isDubbingActive: Boolean
|
||||
get() = dubbingPlayback.isActive
|
||||
|
||||
fun onScreenOff() {
|
||||
if (!dubbingPlayback.isActive) {
|
||||
controller.pause()
|
||||
return
|
||||
}
|
||||
dubbingPlayback.stop(controller)
|
||||
refreshTestUi()
|
||||
host.setMainStatus("已停止配音播放")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
remoteLoadGeneration.incrementAndGet()
|
||||
attemptRecorder.discard()
|
||||
dubbingPlayback.release()
|
||||
background.shutdownNow()
|
||||
}
|
||||
|
||||
fun stopDubbingIfNeeded() {
|
||||
if (dubbingPlayback.isActive) {
|
||||
dubbingPlayback.stop(controller)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads local (and if needed remote) history for the active cloud course. */
|
||||
fun ensureHistoryForCurrentCourse(forceRemote: Boolean = false) {
|
||||
val videoHash = currentVideoHash()
|
||||
if (videoHash == null) {
|
||||
if (loadedVideoHash != null) {
|
||||
loadedVideoHash = null
|
||||
dubbingState.clearMemory()
|
||||
assessmentState.clear()
|
||||
historyStatusMessage = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (loadedVideoHash == videoHash && !forceRemote) {
|
||||
return
|
||||
}
|
||||
loadLocalHistory(videoHash)
|
||||
if (forceRemote || dubbingState.segments.isEmpty()) {
|
||||
restoreRemoteHistory(videoHash)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTestUi() {
|
||||
ensureHistoryForCurrentCourse()
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
applyTestSubtitleVisibility()
|
||||
ui.recordButton.text = if (attemptRecorder.isRecording) "停止并评测" else "开始录音"
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
val currentRecording = sentence?.let { dubbingState.segments[it.index] }
|
||||
val segmentLabel = when {
|
||||
currentRecording != null -> "本句已有配音,重录将覆盖"
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (!assessing && !attemptRecorder.isRecording) {
|
||||
val score = sentence?.let { assessmentState.get(it.index) }
|
||||
if (score != null) {
|
||||
applyAssessmentResult(score, segmentLabel)
|
||||
} else {
|
||||
ui.testStatusText.text = when {
|
||||
item == null || sentence == null -> "请先在训练模块选择课程"
|
||||
segmentLabel != null -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句) · $segmentLabel"
|
||||
else -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
|
||||
}
|
||||
ui.scoreSummaryText.text = "尚未评测"
|
||||
ui.scoreDetailText.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
refreshDubButtons()
|
||||
if (!sharing) {
|
||||
val historyNote = historyStatusMessage
|
||||
if (dubbingState.mergedFile?.exists() == true) {
|
||||
ui.dubbingStatusText?.text = historyNote
|
||||
?: "已合成 ${dubbingState.segments.size} 句配音,可点击“分享配音”"
|
||||
} else {
|
||||
ui.dubbingStatusText?.text = historyNote ?: when (dubbingState.segments.size) {
|
||||
0 -> "配音:请先录音(进入本页会自动加载历史配音)"
|
||||
1 -> "配音:已保存 1 句,可生成配音"
|
||||
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||
}
|
||||
}
|
||||
ui.dubbingStatusText?.setTextColor(SampleTheme.accentDeep)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (!attemptRecorder.isRecording) {
|
||||
startRecording()
|
||||
} else {
|
||||
stopRecordingAndAssess()
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
stopDubbingIfNeeded()
|
||||
controller.pause()
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
activity.requestPermissions(
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
RECORD_AUDIO_REQUEST,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
attemptRecorder.start(activity.cacheDir)
|
||||
val sentence = controller.currentSentence()
|
||||
if (sentence != null) {
|
||||
controller.seekTo(sentence.startMs)
|
||||
controller.setVolume(0f)
|
||||
}
|
||||
controller.play()
|
||||
ui.recordButton.text = "停止并保存"
|
||||
ui.testStatusText.text = if (sentence == null) {
|
||||
"正在录音…读完当前句子后点击“停止并保存”"
|
||||
} else {
|
||||
"正在录音…请对照静音视频口形,读完点击“停止并保存”"
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
ui.testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
||||
attemptRecorder.release()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDubPlayback() {
|
||||
if (dubbingPlayback.isActive) {
|
||||
dubbingPlayback.stop(controller)
|
||||
refreshTestUi()
|
||||
host.setMainStatus("已停止配音播放")
|
||||
return
|
||||
}
|
||||
val sentence = controller.currentSentence()
|
||||
if (sentence == null) {
|
||||
host.setMainStatus("请先选择要播放的句子")
|
||||
return
|
||||
}
|
||||
val audio = dubbingState.segments[sentence.index]
|
||||
if (audio == null || !audio.exists()) {
|
||||
host.setMainStatus("当前句还没有录音,请先录音后再播放")
|
||||
refreshTestUi()
|
||||
return
|
||||
}
|
||||
startDubPlayback(audio, sentence.startMs, sentence.index)
|
||||
}
|
||||
|
||||
fun mergeDubSegments() {
|
||||
stopDubbingIfNeeded()
|
||||
val videoHash = currentVideoHash()
|
||||
val segments = dubbingState.segments.toSortedMap().map { (index, file) -> index to file }
|
||||
if (segments.isEmpty()) {
|
||||
setDubbingStatus("没有可合成的配音")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val output = dubbingMerger.merge(segments)
|
||||
val persisted = if (videoHash != null) {
|
||||
historyStore.saveMerged(videoHash, output).also {
|
||||
if (it.absolutePath != output.absolutePath) {
|
||||
output.delete()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output
|
||||
}
|
||||
dubbingState.setMergedFile(persisted)
|
||||
historyStatusMessage = null
|
||||
refreshTestUi()
|
||||
setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "merge failed", error)
|
||||
setDubbingStatus(
|
||||
"生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}" +
|
||||
" · 已保存 ${dubbingState.segments.size} 句",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun shareMergedDubbing() {
|
||||
stopDubbingIfNeeded()
|
||||
if (sharing) {
|
||||
return
|
||||
}
|
||||
val item = controller.currentTrainingItem()
|
||||
if (item == null || !PlaybackFormat.isVideoHash(item.id)) {
|
||||
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
|
||||
return
|
||||
}
|
||||
val token = tokenProvider().trim()
|
||||
if (token.isEmpty()) {
|
||||
setDubbingStatus("分享失败:请先登录后再分享配音")
|
||||
return
|
||||
}
|
||||
val segmentsSnapshot = dubbingState.segments.toMap()
|
||||
val scoresSnapshot = assessmentState.toMap()
|
||||
val videoHash = item.id
|
||||
val videoTitle = item.title
|
||||
sharing = true
|
||||
setDubbingStatus("正在生成分享链接,请稍候...", alert = true)
|
||||
refreshDubButtons()
|
||||
background.execute {
|
||||
try {
|
||||
val shareUrl = dubbingShareApi.upload(
|
||||
baseUrl = SERVER_BASE_URL,
|
||||
token = token,
|
||||
videoHash = videoHash,
|
||||
videoTitle = videoTitle,
|
||||
segments = segmentsSnapshot,
|
||||
scores = scoresSnapshot,
|
||||
)
|
||||
mainHandler.post {
|
||||
sharing = false
|
||||
if (!host.bindings.hasTestChrome) {
|
||||
return@post
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
|
||||
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
activity.startActivity(Intent.createChooser(intent, "分享配音"))
|
||||
setDubbingStatus("已生成分享链接:$shareUrl")
|
||||
refreshDubButtons()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "share failed", error)
|
||||
val detail = error.message?.takeIf { it.isNotBlank() }
|
||||
?: error.javaClass.simpleName
|
||||
mainHandler.post {
|
||||
sharing = false
|
||||
setDubbingStatus("分享失败:$detail", alert = true)
|
||||
refreshDubButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onTestSubtitleChanged(visible: Boolean) {
|
||||
screenState.testSubtitleVisible = visible
|
||||
screenState.landscapeSubtitleVisible = visible
|
||||
host.rebuildContent()
|
||||
host.refreshPlayerUi()
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun loadLocalHistory(videoHash: String) {
|
||||
val snapshot = historyStore.loadSnapshot(videoHash)
|
||||
dubbingState.replaceAll(snapshot.segments, snapshot.mergedFile)
|
||||
assessmentState.replaceAll(snapshot.scores)
|
||||
loadedVideoHash = videoHash
|
||||
historyStatusMessage = when {
|
||||
snapshot.segments.isNotEmpty() && snapshot.scores.isNotEmpty() ->
|
||||
"已加载历史配音 ${snapshot.segments.size} 句、评分 ${snapshot.scores.size} 句"
|
||||
snapshot.segments.isNotEmpty() ->
|
||||
"已加载历史配音 ${snapshot.segments.size} 句"
|
||||
snapshot.scores.isNotEmpty() ->
|
||||
"已加载历史评分 ${snapshot.scores.size} 句(暂无本地录音)"
|
||||
else -> null
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"loaded local history video=$videoHash segments=${snapshot.segments.size} scores=${snapshot.scores.size}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreRemoteHistory(videoHash: String) {
|
||||
val token = tokenProvider().trim()
|
||||
if (token.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val generation = remoteLoadGeneration.incrementAndGet()
|
||||
historyStatusMessage = when {
|
||||
dubbingState.segments.isEmpty() && assessmentState.size == 0 -> "正在同步历史配音…"
|
||||
else -> historyStatusMessage
|
||||
}
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.dubbingStatusText?.text = historyStatusMessage
|
||||
}
|
||||
|
||||
// Latest score summary per sentence.
|
||||
userApi.results(token) { result ->
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@results
|
||||
}
|
||||
result.onSuccess { rows ->
|
||||
val latestBySentence = linkedMapOf<Int, cn.learningpad.oraltrainer.sdk.UserResult>()
|
||||
rows.filter { it.videoHash.equals(videoHash, ignoreCase = true) }
|
||||
.sortedBy { it.createdAt }
|
||||
.forEach { latestBySentence[it.sentenceIndex] = it }
|
||||
var added = 0
|
||||
latestBySentence.forEach { (index, row) ->
|
||||
if (assessmentState.get(index) == null) {
|
||||
assessmentState.store(
|
||||
index,
|
||||
ImitationAssessmentResult(
|
||||
overallScore = row.overallScore,
|
||||
passed = row.passed,
|
||||
referenceText = row.sentenceText.takeIf { it.isNotBlank() },
|
||||
feedback = "来自历史测试记录",
|
||||
),
|
||||
)
|
||||
added += 1
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
historyStore.saveScores(videoHash, assessmentState.toMap())
|
||||
historyStatusMessage = "已同步历史评分 ${assessmentState.size} 句"
|
||||
refreshTestUi()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Latest shared dubbing audio for this course.
|
||||
userApi.dubShares(token) { shareResult ->
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@dubShares
|
||||
}
|
||||
val latestShare = shareResult.getOrNull()
|
||||
?.filter { it.videoHash.equals(videoHash, ignoreCase = true) }
|
||||
?.maxByOrNull { it.createdAt }
|
||||
if (latestShare == null) {
|
||||
if (dubbingState.segments.isEmpty() && assessmentState.size == 0) {
|
||||
historyStatusMessage = "暂无历史配音,请开始录音"
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.dubbingStatusText?.text = historyStatusMessage
|
||||
}
|
||||
}
|
||||
return@dubShares
|
||||
}
|
||||
if (dubbingState.segments.isNotEmpty()) {
|
||||
return@dubShares
|
||||
}
|
||||
userApi.dubShareDetail(latestShare.shareId) { detailResult ->
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@dubShareDetail
|
||||
}
|
||||
val detail = detailResult.getOrNull() ?: run {
|
||||
Log.w(TAG, "dub share detail failed: ${detailResult.exceptionOrNull()?.message}")
|
||||
return@dubShareDetail
|
||||
}
|
||||
background.execute {
|
||||
val downloaded = linkedMapOf<Int, File>()
|
||||
val remoteScores = linkedMapOf<Int, ImitationAssessmentResult>()
|
||||
detail.segments.forEach { segment ->
|
||||
val file = historyStore.downloadSegmentAudio(
|
||||
baseUrl = SERVER_BASE_URL,
|
||||
videoHash = videoHash,
|
||||
sentenceIndex = segment.index,
|
||||
audioUrl = segment.audioUrl,
|
||||
)
|
||||
if (file != null) {
|
||||
downloaded[segment.index] = file
|
||||
}
|
||||
if (assessmentState.get(segment.index) == null) {
|
||||
remoteScores[segment.index] = ImitationAssessmentResult(
|
||||
overallScore = segment.overallScore,
|
||||
contentScore = segment.scoreDetails["content_score"],
|
||||
fluencyScore = segment.scoreDetails["fluency_score"],
|
||||
durationScore = segment.scoreDetails["duration_score"],
|
||||
pauseScore = segment.scoreDetails["pause_score"],
|
||||
speechRateScore = segment.scoreDetails["speech_rate_score"],
|
||||
recognizedText = segment.recognizedText.takeIf { it.isNotBlank() },
|
||||
feedback = "来自历史配音分享",
|
||||
)
|
||||
}
|
||||
}
|
||||
mainHandler.post {
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@post
|
||||
}
|
||||
if (downloaded.isNotEmpty()) {
|
||||
val mergedSegments = dubbingState.segments.toMutableMap()
|
||||
mergedSegments.putAll(downloaded)
|
||||
dubbingState.replaceAll(mergedSegments, dubbingState.mergedFile)
|
||||
}
|
||||
if (remoteScores.isNotEmpty()) {
|
||||
val mergedScores = assessmentState.toMap().toMutableMap()
|
||||
remoteScores.forEach { (index, score) ->
|
||||
if (mergedScores[index] == null) {
|
||||
mergedScores[index] = score
|
||||
}
|
||||
}
|
||||
assessmentState.replaceAll(mergedScores)
|
||||
historyStore.saveScores(videoHash, assessmentState.toMap())
|
||||
}
|
||||
historyStatusMessage = when {
|
||||
downloaded.isNotEmpty() ->
|
||||
"已从云端恢复配音 ${downloaded.size} 句"
|
||||
assessmentState.size > 0 ->
|
||||
"已同步历史评分 ${assessmentState.size} 句"
|
||||
else -> "暂无可用的历史配音音频"
|
||||
}
|
||||
refreshTestUi()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingAndAssess() {
|
||||
if (!attemptRecorder.isRecording) return
|
||||
val ui = host.bindings
|
||||
ui.recordButton.text = "重新录音"
|
||||
val file = try {
|
||||
attemptRecorder.stop()
|
||||
} catch (error: Throwable) {
|
||||
ui.testStatusText.text = "录音太短或无法保存:${error.message.orEmpty()}"
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
controller.setVolume(1f)
|
||||
val sentenceIndex = controller.currentSentence()?.index
|
||||
if (sentenceIndex == null) {
|
||||
ui.testStatusText.text = "没有可归属的句子,本次录音已丢弃"
|
||||
file.delete()
|
||||
attemptRecorder.release()
|
||||
refreshTestUi()
|
||||
return
|
||||
}
|
||||
val videoHash = currentVideoHash()
|
||||
val persisted = if (videoHash != null) {
|
||||
try {
|
||||
historyStore.saveSegment(videoHash, sentenceIndex, file).also {
|
||||
if (it.absolutePath != file.absolutePath) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "persist segment failed", error)
|
||||
file
|
||||
}
|
||||
} else {
|
||||
file
|
||||
}
|
||||
dubbingState.storeSegment(sentenceIndex, persisted)
|
||||
historyStatusMessage = null
|
||||
submitAssessment(Uri.fromFile(persisted), sentenceIndex)
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun submitAssessment(recordingUri: Uri, sentenceIndex: Int) {
|
||||
val ui = host.bindings
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
if (item == null || sentence == null) {
|
||||
ui.testStatusText.text = "没有正在学习的句子,无法评测"
|
||||
return
|
||||
}
|
||||
if (!PlaybackFormat.isVideoHash(item.id)) {
|
||||
ui.testStatusText.text = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
|
||||
return
|
||||
}
|
||||
if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) {
|
||||
ui.testStatusText.text = "未配置评分密钥:请在 AppConfig 填入服务器 .env 的 CLIENT_API_KEY"
|
||||
return
|
||||
}
|
||||
assessing = true
|
||||
ui.testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
|
||||
ui.scoreSummaryText.text = "评测中…"
|
||||
ui.scoreDetailText.text = ""
|
||||
controller.assessCurrentSentence(
|
||||
recordingUri = recordingUri,
|
||||
locale = sentence.language
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: item.sentences.getOrNull(sentence.index)?.language,
|
||||
metadata = mapOf("video_hash" to item.id),
|
||||
callback = object : ImitationAssessmentCallback {
|
||||
override fun onResult(result: ImitationAssessmentResult) {
|
||||
assessing = false
|
||||
assessmentState.store(sentenceIndex, result)
|
||||
currentVideoHash()?.let { hash ->
|
||||
historyStore.saveScores(hash, assessmentState.toMap())
|
||||
}
|
||||
if (host.bindings.hasTestChrome) {
|
||||
applyAssessmentResult(result)
|
||||
refreshDubButtons()
|
||||
host.bindings.dubbingStatusText?.text = when (dubbingState.segments.size) {
|
||||
0 -> "配音:请先录音"
|
||||
1 -> "配音:已保存 1 句,可生成配音"
|
||||
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
assessing = false
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.testStatusText.text = "评测失败:${error.message.orEmpty()}"
|
||||
host.bindings.scoreSummaryText.text = "评测失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyAssessmentResult(
|
||||
result: ImitationAssessmentResult,
|
||||
segmentLabel: String? = null,
|
||||
) {
|
||||
val ui = host.bindings
|
||||
val passLabel = when (result.passed) {
|
||||
true -> "已通过"
|
||||
false -> "未通过"
|
||||
null -> "已评测"
|
||||
}
|
||||
ui.testStatusText.text = listOfNotNull(passLabel, segmentLabel).joinToString(" · ")
|
||||
ui.scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
|
||||
ui.scoreDetailText.text = buildString {
|
||||
result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) }
|
||||
result.fluencyScore?.let { appendLine(String.format(Locale.US, "流畅度 %.1f", it)) }
|
||||
result.durationScore?.let { appendLine(String.format(Locale.US, "时长分 %.1f", it)) }
|
||||
result.pauseScore?.let { appendLine(String.format(Locale.US, "停顿分 %.1f", it)) }
|
||||
result.speechRateScore?.let { appendLine(String.format(Locale.US, "语速分 %.1f", it)) }
|
||||
result.referenceText?.let { appendLine("参考:$it") }
|
||||
result.recognizedText?.let { appendLine("识别:$it") }
|
||||
if (result.missingTokens.isNotEmpty()) {
|
||||
appendLine("漏读:${result.missingTokens.joinToString("、")}")
|
||||
}
|
||||
if (result.extraTokens.isNotEmpty()) {
|
||||
appendLine("多读:${result.extraTokens.joinToString("、")}")
|
||||
}
|
||||
result.feedback?.let { appendLine(it) }
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
private fun applyTestSubtitleVisibility() {
|
||||
if (screenState.activeModule != Module.TEST) {
|
||||
return
|
||||
}
|
||||
screenState.landscapeSubtitleVisible = screenState.testSubtitleVisible
|
||||
val ui = host.bindings
|
||||
if (!ui.hasPlayerChrome) {
|
||||
return
|
||||
}
|
||||
val visibility = if (screenState.testSubtitleVisible) View.VISIBLE else View.GONE
|
||||
ui.sentenceMetaText.visibility = visibility
|
||||
ui.sentenceText.visibility = visibility
|
||||
ui.progressBar.visibility = visibility
|
||||
}
|
||||
|
||||
private fun refreshDubButtons() {
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
val dubbingActive = dubbingPlayback.isActive
|
||||
val currentSentenceAudio = controller.currentSentence()?.let { dubbingState.segments[it.index] }
|
||||
// Keep the play button tappable while active so the user can stop playback.
|
||||
ui.playDubButton.isEnabled = dubbingActive || currentSentenceAudio != null
|
||||
ui.mergeDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
||||
ui.shareDubButton.isEnabled =
|
||||
dubbingState.mergedFile?.exists() == true && !dubbingActive && !sharing
|
||||
ui.playDubButton.text = when {
|
||||
dubbingActive -> "停止播放"
|
||||
currentSentenceAudio != null -> "播放本句录音"
|
||||
dubbingState.segments.isNotEmpty() -> "播放本句录音"
|
||||
else -> "播放录音"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDubPlayback(audioFile: File, sentenceStartMs: Long, sentenceIndex: Int) {
|
||||
dubbingPlayback.start(
|
||||
audioFile = audioFile,
|
||||
controller = controller,
|
||||
sentenceStartMs = sentenceStartMs,
|
||||
sentenceIndex = sentenceIndex,
|
||||
) {
|
||||
refreshTestUi()
|
||||
host.setMainStatus("本句录音播放完成")
|
||||
}
|
||||
refreshTestUi()
|
||||
host.setMainStatus("正在播放当前句录音,原视频画面同步但原声静音")
|
||||
}
|
||||
|
||||
private fun setDubbingStatus(message: String, alert: Boolean = false) {
|
||||
historyStatusMessage = null
|
||||
host.bindings.dubbingStatusText?.apply {
|
||||
text = message
|
||||
setTextColor(if (alert) SampleTheme.danger else SampleTheme.accentDeep)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentVideoHash(): String? {
|
||||
val id = controller.currentTrainingItem()?.id
|
||||
?: screenState.currentCourse?.videoHash
|
||||
?: screenState.activeItemId
|
||||
return DubbingHistoryStore.normalize(id.orEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "Dubbing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
|
||||
/** Keeps the training player alive after the screen turns off or the app backgrounds. */
|
||||
class TrainingPlaybackService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.training_playback_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.training_playback_notification_title))
|
||||
.setContentText(getString(R.string.training_playback_notification_text))
|
||||
.setSmallIcon(R.drawable.ic_notification_play)
|
||||
.setContentIntent(contentIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "training_playback"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, TrainingPlaybackService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, TrainingPlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
internal class TrainingScreenState {
|
||||
var activeItemId = ""
|
||||
var currentSentenceCount = 0
|
||||
var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||
val courseEnrollment = mutableMapOf<String, Boolean>()
|
||||
val courseBoundaries = mutableMapOf<String, SentenceBoundaryApiResult>()
|
||||
var catalogStatus = "正在同步"
|
||||
var catalogIsRefreshing = false
|
||||
var activeModule = Module.COURSE
|
||||
var currentCourse: TrainingVideoSummary? = null
|
||||
var continuousPlaybackEnabled = false
|
||||
var chineseSubtitlesEnabled = false
|
||||
var readingTranslationVisible = true
|
||||
var chineseTranslationLoading = false
|
||||
var chineseTranslationError = ""
|
||||
var testSubtitleVisible = false
|
||||
var landscapeSubtitleVisible = true
|
||||
|
||||
private var boundaryRequestGeneration = 0
|
||||
private var catalogRequestGeneration = 0
|
||||
private var translationRequestGeneration = 0
|
||||
|
||||
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
|
||||
|
||||
fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean =
|
||||
boundaryRequestGeneration == generation && activeItemId == itemId
|
||||
|
||||
fun beginCatalogRequest(): Int = ++catalogRequestGeneration
|
||||
|
||||
fun isCurrentCatalogRequest(generation: Int): Boolean =
|
||||
catalogRequestGeneration == generation
|
||||
|
||||
fun beginTranslationRequest(): Int = ++translationRequestGeneration
|
||||
|
||||
fun isCurrentTranslationRequest(generation: Int, itemId: String): Boolean =
|
||||
translationRequestGeneration == generation && activeItemId == itemId
|
||||
|
||||
fun clearCourse() {
|
||||
currentCourse = null
|
||||
activeItemId = ""
|
||||
currentSentenceCount = 0
|
||||
catalogVideos = emptyList()
|
||||
courseEnrollment.clear()
|
||||
courseBoundaries.clear()
|
||||
catalogStatus = "正在同步"
|
||||
catalogIsRefreshing = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.widget.LinearLayout
|
||||
|
||||
/** Shared density/style helpers for Activity and coordinator UI builders. */
|
||||
internal interface UiStyled {
|
||||
val uiContext: Context
|
||||
|
||||
val Int.dp: Int
|
||||
get() = (this * uiContext.resources.displayMetrics.density).toInt()
|
||||
|
||||
val Float.dp: Int
|
||||
get() = (this * uiContext.resources.displayMetrics.density).toInt()
|
||||
|
||||
fun rounded(
|
||||
color: Int,
|
||||
radiusDp: Float,
|
||||
strokeColor: Int? = null,
|
||||
): GradientDrawable = GradientDrawable().apply {
|
||||
setColor(color)
|
||||
cornerRadius = radiusDp.dp.toFloat()
|
||||
strokeColor?.let { setStroke(1.dp, it) }
|
||||
}
|
||||
|
||||
fun LinearLayout.LayoutParams.withMargins(
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
): LinearLayout.LayoutParams = apply {
|
||||
setMargins(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.CourseScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class CourseScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: CourseScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createSection(): View {
|
||||
bindings.catalogStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
textSize = 13f
|
||||
text = screenState.catalogStatus
|
||||
setPadding(16.dp, 0.dp, 16.dp, 8.dp)
|
||||
}
|
||||
bindings.catalogList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 8.dp, 16.dp, 16.dp)
|
||||
}
|
||||
val header = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(16.dp, 0.dp, 16.dp, 0.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "课程首页"
|
||||
textSize = 26f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
bindings.catalogRefreshButton = widgets.headerButton("刷新课程") {
|
||||
actions.onRefreshCourses()
|
||||
}.also { button ->
|
||||
button.isEnabled = !screenState.catalogIsRefreshing
|
||||
button.alpha = if (screenState.catalogIsRefreshing) 0.55f else 1f
|
||||
addView(button)
|
||||
}
|
||||
}
|
||||
return ScrollView(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0.dp, 12.dp, 0.dp, 16.dp)
|
||||
addView(header)
|
||||
addView(TextView(activity).apply {
|
||||
text = "选择一门课程开始训练"
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(16.dp, 4.dp, 16.dp, 8.dp)
|
||||
})
|
||||
addView(bindings.catalogStatusText)
|
||||
addView(bindings.catalogList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun renderCatalog() {
|
||||
if (!bindings.hasCatalogChrome) return
|
||||
bindings.catalogList.removeAllViews()
|
||||
bindings.catalogStatusText.text = screenState.catalogStatus
|
||||
if (screenState.catalogVideos.isEmpty()) {
|
||||
bindings.catalogList.addView(widgets.infoCard("暂无课程"))
|
||||
return
|
||||
}
|
||||
screenState.catalogVideos.forEach { video ->
|
||||
bindings.catalogList.addView(
|
||||
widgets.courseListCard(video) { actions.onSelectCourse(video) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLoading() {
|
||||
if (!bindings.hasCatalogChrome) return
|
||||
bindings.catalogList.removeAllViews()
|
||||
bindings.catalogList.addView(widgets.infoCard("课程加载中..."))
|
||||
}
|
||||
|
||||
fun setStatus(text: String) {
|
||||
if (bindings.hasCatalogChrome) {
|
||||
bindings.catalogStatusText.text = text
|
||||
}
|
||||
bindings.catalogRefreshButton?.let { button ->
|
||||
button.isEnabled = !screenState.catalogIsRefreshing
|
||||
button.alpha = if (screenState.catalogIsRefreshing) 0.55f else 1f
|
||||
button.text = if (screenState.catalogIsRefreshing) "刷新中" else "刷新课程"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sample.MineScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
internal class MineScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: MineScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private val deletingHistoryIds = mutableSetOf<String>()
|
||||
|
||||
fun createSection(): View {
|
||||
bindings.myContentList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
bindings.favoriteSentenceList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
return ScrollView(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = actions.nickname().ifBlank { "我的学习" }
|
||||
textSize = 26f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "退出登录"
|
||||
setOnClickListener { actions.onLogout() }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).withMargins(0, 12.dp, 0, 20.dp)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "本视频收藏句子"
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(bindings.favoriteSentenceList)
|
||||
addView(TextView(activity).apply {
|
||||
text = "我的测试结果"
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(bindings.myContentList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshContent(userApi: UserApi, token: String) {
|
||||
renderFavoriteSentences()
|
||||
val list = bindings.myContentList ?: return
|
||||
list.removeAllViews()
|
||||
val course = screenState.currentCourse
|
||||
if (course == null) {
|
||||
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
|
||||
return
|
||||
}
|
||||
list.addView(courseHeading(course.title))
|
||||
list.addView(widgets.infoCard("加载中..."))
|
||||
userApi.results(token) { result ->
|
||||
list.removeAllViews()
|
||||
list.addView(courseHeading(course.title))
|
||||
result.fold(
|
||||
onSuccess = { rows ->
|
||||
val filtered = rows.filter { it.videoHash == course.videoHash }
|
||||
if (filtered.isEmpty()) list.addView(widgets.infoCard("该课程还没有测试记录"))
|
||||
filtered.forEach { row ->
|
||||
val passedLabel = if (row.passed) "通过" else "未通过"
|
||||
list.addView(
|
||||
historyCard(
|
||||
text = "第 ${row.sentenceIndex + 1} 句" + "\n" +
|
||||
"得分 ${row.overallScore.toInt()} · $passedLabel",
|
||||
id = row.attemptId,
|
||||
) {
|
||||
deleteResult(userApi, token, row.attemptId)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { list.addView(widgets.infoCard("测试记录加载失败:${it.message}")) },
|
||||
)
|
||||
userApi.dubShares(token) { shareResult ->
|
||||
list.post {
|
||||
list.addView(widgets.titleDivider("我的配音分享"))
|
||||
shareResult.fold(
|
||||
onSuccess = { shares ->
|
||||
val filtered = shares.filter { it.videoHash == course.videoHash }
|
||||
if (filtered.isEmpty()) {
|
||||
list.addView(widgets.infoCard("该课程还没有分享配音"))
|
||||
}
|
||||
filtered.forEach { row ->
|
||||
list.addView(
|
||||
historyCard(
|
||||
text = "${row.title}" + "\n" +
|
||||
"${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分",
|
||||
id = row.shareId,
|
||||
) {
|
||||
deleteDubShare(userApi, token, row.shareId)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
list.addView(widgets.infoCard("配音分享加载失败:${it.message}"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFavoriteSentences() {
|
||||
val list = bindings.favoriteSentenceList ?: return
|
||||
list.removeAllViews()
|
||||
if (screenState.currentCourse == null) {
|
||||
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
|
||||
return
|
||||
}
|
||||
val favorites = actions.favoriteSentences()
|
||||
if (favorites.isEmpty()) {
|
||||
list.addView(widgets.infoCard("当前课程还没有收藏句子"))
|
||||
return
|
||||
}
|
||||
favorites.forEach { sentence ->
|
||||
list.addView(favoriteCard(sentence) {
|
||||
if (!actions.toggleSentenceFavorite(sentence)) {
|
||||
renderFavoriteSentences()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun favoriteCard(
|
||||
sentence: SentenceBoundary,
|
||||
onRemove: () -> Unit,
|
||||
): View {
|
||||
val foreign = sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"
|
||||
val translation = sentence.zhText?.takeIf { it.isNotBlank() }
|
||||
val cardText = buildString {
|
||||
append("第 ${sentence.index + 1} 句")
|
||||
append("\n$foreign")
|
||||
translation?.let { append("\n$it") }
|
||||
}
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 10.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = cardText
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "移除"
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(10.dp, 0, 10.dp, 0)
|
||||
background = rounded(SampleTheme.danger, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
32.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener { onRemove() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteResult(userApi: UserApi, token: String, attemptId: String) {
|
||||
userApi.deleteResult(token, attemptId) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
Toast.makeText(activity, "测试记录已删除", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
onFailure = {
|
||||
deletingHistoryIds.remove(attemptId)
|
||||
Toast.makeText(activity, "删除失败:${it.message}", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDubShare(userApi: UserApi, token: String, shareId: String) {
|
||||
userApi.deleteDubShare(token, shareId) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
Toast.makeText(activity, "配音分享已删除", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
onFailure = {
|
||||
deletingHistoryIds.remove(shareId)
|
||||
Toast.makeText(activity, "删除失败:${it.message}", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun historyCard(
|
||||
text: String,
|
||||
id: String,
|
||||
onDelete: () -> Unit,
|
||||
): View {
|
||||
val isDeleting = id in deletingHistoryIds
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 10.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
|
||||
addView(TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
setText(if (isDeleting) "删除中..." else "删除")
|
||||
isAllCaps = false
|
||||
isEnabled = !isDeleting
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(10.dp, 0, 10.dp, 0)
|
||||
background = rounded(SampleTheme.danger, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
32.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener {
|
||||
if (!deletingHistoryIds.add(id)) return@setOnClickListener
|
||||
setText("删除中...")
|
||||
isEnabled = false
|
||||
onDelete()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun courseHeading(title: String): TextView =
|
||||
TextView(activity).apply {
|
||||
text = "当前课程:$title"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.graphics.drawable.InsetDrawable
|
||||
import android.graphics.Typeface
|
||||
import android.text.TextUtils
|
||||
import android.view.MotionEvent
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.TranslateAnimation
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import cn.learningpad.oraltrainer.sample.Module
|
||||
import cn.learningpad.oraltrainer.sample.PROGRESS_MAX
|
||||
import cn.learningpad.oraltrainer.sample.R
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
|
||||
/** Player surface, overlays, sentence panel, and landscape subtitle chrome. */
|
||||
internal class PlayerChrome(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val controller: OralTrainerController,
|
||||
private val widgets: SampleWidgets,
|
||||
private val seekActions: TrainScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createPlayerSection(): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(SampleTheme.surface, 8f, SampleTheme.border)
|
||||
clipToOutline = true
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(16.dp, 0, 16.dp, 10.dp)
|
||||
addView(
|
||||
createPlayerOverlay().apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
},
|
||||
)
|
||||
addView(
|
||||
object : FrameLayout(activity) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = (width * 9) / 16
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
addView(createPlayerView())
|
||||
addView(
|
||||
createRotationButton().apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
44.dp,
|
||||
44.dp,
|
||||
Gravity.BOTTOM or Gravity.END,
|
||||
).apply {
|
||||
marginEnd = 12.dp
|
||||
bottomMargin = 12.dp
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createSentenceSection(): View =
|
||||
createSentenceContent(translucent = false).apply {
|
||||
background = rounded(Color.WHITE, 8f, SampleTheme.lightBorder)
|
||||
setPadding(18.dp, 16.dp, 18.dp, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
).withMargins(16.dp, 0, 16.dp, 12.dp)
|
||||
}
|
||||
|
||||
fun createLandscapeContentView(): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
|
||||
val bars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
|
||||
)
|
||||
val gestures = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures())
|
||||
view.setPadding(
|
||||
bars.left,
|
||||
bars.top,
|
||||
bars.right,
|
||||
maxOf(bars.bottom, gestures.bottom, 24.dp),
|
||||
)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
addView(
|
||||
createPlayerOverlay(compact = true).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
},
|
||||
)
|
||||
addView(
|
||||
FrameLayout(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
addView(
|
||||
object : FrameLayout(activity) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val maxHeight = MeasureSpec.getSize(heightMeasureSpec)
|
||||
val heightFromWidth = if (maxWidth > 0) (maxWidth * 9) / 16 else 0
|
||||
val width: Int
|
||||
val height: Int
|
||||
if (heightFromWidth <= maxHeight) {
|
||||
width = maxWidth
|
||||
height = heightFromWidth
|
||||
} else {
|
||||
height = maxHeight
|
||||
width = (maxHeight * 16) / 9
|
||||
}
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.TOP or Gravity.CENTER_HORIZONTAL,
|
||||
)
|
||||
addView(createPlayerView())
|
||||
},
|
||||
)
|
||||
val subtitleContainer = object : LinearLayout(activity) {
|
||||
private var forwardTouchToPlayer = false
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
forwardTouchToPlayer = !isTouchOverInteractiveControl(this, event)
|
||||
}
|
||||
val forwarded = if (forwardTouchToPlayer) {
|
||||
bindings.playerView.forwardGestureFrom(this, event)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val handled = forwarded || super.dispatchTouchEvent(event)
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP ||
|
||||
event.actionMasked == MotionEvent.ACTION_CANCEL
|
||||
) {
|
||||
forwardTouchToPlayer = false
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
}.apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
addView(
|
||||
createLandscapeSentencePanel().apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
addView(subtitleContainer)
|
||||
applyLandscapeSubtitleState(subtitleContainer, animate = false)
|
||||
installLandscapeSwipeGesture(subtitleContainer)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createSentenceContent(translucent: Boolean): LinearLayout {
|
||||
bindings.lessonTitleText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else SampleTheme.textDark)
|
||||
textSize = 17f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 1
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
}
|
||||
bindings.sentenceMetaText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
bindings.sentenceText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else SampleTheme.textDark)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 10.dp, 0, 4.dp)
|
||||
}
|
||||
bindings.sentenceZhText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 16f
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
visibility = View.GONE
|
||||
}
|
||||
bindings.progressBar = SeekBar(activity).apply {
|
||||
max = PROGRESS_MAX
|
||||
progress = 0
|
||||
progressTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.progressTrackDark else SampleTheme.progressTrack,
|
||||
)
|
||||
thumbTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
28.dp,
|
||||
)
|
||||
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) seekActions.onSeekProgressPreview(progress)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
seekActions.onSeekStart()
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
seekActions.onSeekStop(seekBar.progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
bindings.statusText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
if (translucent) {
|
||||
background = rounded(SampleTheme.overlayFaint, 0f, null)
|
||||
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
|
||||
}
|
||||
val topContent = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
if (screenState.activeModule != Module.TRAIN) {
|
||||
addView(bindings.lessonTitleText)
|
||||
}
|
||||
if (screenState.activeModule == Module.TRAIN) {
|
||||
addView(createContinuousPlaybackRow(translucent))
|
||||
addView(createChineseSubtitleRow(translucent))
|
||||
}
|
||||
addView(bindings.sentenceMetaText)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.TOP
|
||||
addView(
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(
|
||||
bindings.sentenceText,
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
addView(
|
||||
bindings.sentenceZhText,
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
},
|
||||
LinearLayout.LayoutParams(
|
||||
0,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
1f,
|
||||
),
|
||||
)
|
||||
bindings.explainSentenceButton = createExplainButton(translucent)
|
||||
addView(
|
||||
bindings.explainSentenceButton,
|
||||
LinearLayout.LayoutParams(32.dp, 32.dp).apply { marginStart = 10.dp },
|
||||
)
|
||||
})
|
||||
}
|
||||
addView(
|
||||
ScrollView(activity).apply {
|
||||
isFillViewport = true
|
||||
isVerticalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(
|
||||
topContent,
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
},
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
if (translucent) ViewGroup.LayoutParams.WRAP_CONTENT else 0,
|
||||
if (translucent) 0f else 1f,
|
||||
),
|
||||
)
|
||||
addView(bindings.progressBar)
|
||||
addView(bindings.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlayerView(): OralTrainerPlayerView =
|
||||
OralTrainerPlayerView(activity).apply {
|
||||
bind(controller)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}.also { bindings.playerView = it }
|
||||
|
||||
private fun createPlayerOverlay(compact: Boolean = false): View {
|
||||
val overlay = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
if (compact) {
|
||||
setPadding(12.dp, 4.dp, 12.dp, 0)
|
||||
} else {
|
||||
setPadding(12.dp, 10.dp, 12.dp, 8.dp)
|
||||
}
|
||||
}
|
||||
bindings.timeText = widgets.overlayPill("--:-- / --:--").apply {
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
bindings.speedText = widgets.overlayPill("1x")
|
||||
overlay.addView(bindings.timeText)
|
||||
overlay.addView(bindings.speedText)
|
||||
return overlay
|
||||
}
|
||||
|
||||
private fun createRotationButton(): ImageButton =
|
||||
ImageButton(activity).apply {
|
||||
setImageResource(R.drawable.ic_screen_rotation)
|
||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
val backgroundSize = 22.dp
|
||||
val buttonSize = 44.dp
|
||||
val inset = (buttonSize - backgroundSize) / 2
|
||||
background = InsetDrawable(
|
||||
GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.argb(102, 9, 12, 16))
|
||||
setSize(backgroundSize, backgroundSize)
|
||||
},
|
||||
inset,
|
||||
)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
setPadding(10.dp, 10.dp, 10.dp, 10.dp)
|
||||
contentDescription = "横屏"
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
clipToOutline = false
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
foreground = null
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(buttonSize, buttonSize)
|
||||
setOnClickListener {
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun createExplainButton(translucent: Boolean): ImageButton =
|
||||
ImageButton(activity).apply {
|
||||
setImageResource(R.drawable.ic_ai_explain)
|
||||
imageTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep,
|
||||
)
|
||||
background = rounded(
|
||||
if (translucent) SampleTheme.overlay else SampleTheme.selected,
|
||||
16.dp.toFloat(),
|
||||
)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
setPadding(6.dp, 6.dp, 6.dp, 6.dp)
|
||||
contentDescription = "AI 讲解这句话"
|
||||
isEnabled = false
|
||||
setOnClickListener { seekActions.onExplainCurrentSentence() }
|
||||
}
|
||||
|
||||
private fun createLandscapeSentencePanel(): View =
|
||||
createSentenceContent(translucent = true).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createContinuousPlaybackRow(translucent: Boolean): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "连续播放"
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.continuousPlaybackEnabled
|
||||
buttonTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accent,
|
||||
)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
seekActions.onContinuousPlaybackChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun createChineseSubtitleRow(translucent: Boolean): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "中文字幕"
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.chineseSubtitlesEnabled
|
||||
buttonTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accent,
|
||||
)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
seekActions.onChineseSubtitlesChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun installLandscapeSwipeGesture(subtitleContainer: View) {
|
||||
bindings.playerView.setVerticalSwipeListener { isSwipeUp ->
|
||||
if (isSwipeUp == screenState.landscapeSubtitleVisible) return@setVerticalSwipeListener
|
||||
screenState.landscapeSubtitleVisible = isSwipeUp
|
||||
applyLandscapeSubtitleState(subtitleContainer, animate = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyLandscapeSubtitleState(container: View, animate: Boolean) {
|
||||
if (!animate || container.visibility == View.GONE) {
|
||||
container.clearAnimation()
|
||||
container.visibility =
|
||||
if (screenState.landscapeSubtitleVisible) View.VISIBLE else View.GONE
|
||||
return
|
||||
}
|
||||
if (screenState.landscapeSubtitleVisible) {
|
||||
container.visibility = View.VISIBLE
|
||||
container.post {
|
||||
if (screenState.landscapeSubtitleVisible && container.visibility == View.VISIBLE) {
|
||||
startSlideAnimation(container, fromYDelta = 1f, toYDelta = 0f) {
|
||||
container.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startSlideAnimation(container, fromYDelta = 0f, toYDelta = 1f) {
|
||||
container.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSlideAnimation(
|
||||
view: View,
|
||||
fromYDelta: Float,
|
||||
toYDelta: Float,
|
||||
onEnd: () -> Unit,
|
||||
) {
|
||||
val animation = TranslateAnimation(0f, 0f, view.height * fromYDelta, view.height * toYDelta).apply {
|
||||
duration = 220L
|
||||
fillAfter = false
|
||||
setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation?) = Unit
|
||||
override fun onAnimationRepeat(animation: Animation?) = Unit
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
view.clearAnimation()
|
||||
onEnd()
|
||||
}
|
||||
})
|
||||
}
|
||||
view.startAnimation(animation)
|
||||
}
|
||||
|
||||
/** Lets controls own their touches while the subtitle background drives player gestures. */
|
||||
private fun isTouchOverInteractiveControl(root: View, event: MotionEvent): Boolean =
|
||||
hasInteractiveTouchTarget(root, event.rawX, event.rawY)
|
||||
|
||||
private fun hasInteractiveTouchTarget(view: View, screenX: Float, screenY: Float): Boolean {
|
||||
if (view.visibility != View.VISIBLE || !view.isEnabled) {
|
||||
return false
|
||||
}
|
||||
if (view.isClickable || view.isLongClickable || view.isContextClickable) {
|
||||
val location = IntArray(2).also(view::getLocationOnScreen)
|
||||
if (screenX >= location[0] &&
|
||||
screenX < location[0] + view.width &&
|
||||
screenY >= location[1] &&
|
||||
screenY < location[1] + view.height
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (view !is ViewGroup) {
|
||||
return false
|
||||
}
|
||||
for (index in view.childCount - 1 downTo 0) {
|
||||
if (hasInteractiveTouchTarget(view.getChildAt(index), screenX, screenY)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.os.SystemClock
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cn.learningpad.oraltrainer.sample.ReadingScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
|
||||
/** Lazy-loading full-text reading view for the selected course. */
|
||||
internal class ReadingScreen(
|
||||
private val activity: Activity,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val controller: OralTrainerController,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: ReadingScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private var readingAdapter: ReadingSentenceAdapter? = null
|
||||
private var titleText: TextView? = null
|
||||
private var sentenceListView: RecyclerView? = null
|
||||
private var lastClickedSentenceIndex = Int.MIN_VALUE
|
||||
private var lastClickedAtMs = 0L
|
||||
|
||||
fun createSection(): View {
|
||||
val root = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||
}
|
||||
val adapter = ReadingSentenceAdapter()
|
||||
readingAdapter = adapter
|
||||
val list = RecyclerView(activity).apply {
|
||||
layoutManager = LinearLayoutManager(activity)
|
||||
this.adapter = adapter
|
||||
itemAnimator = null
|
||||
setItemViewCacheSize(12)
|
||||
clipToPadding = false
|
||||
setPadding(0, 0, 0, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
sentenceListView = list
|
||||
|
||||
val title = TextView(activity).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 0, 0, 6.dp)
|
||||
}
|
||||
titleText = title
|
||||
root.addView(title)
|
||||
root.addView(createTranslationToggle())
|
||||
root.addView(list)
|
||||
renderSentences()
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
fun renderSentences() {
|
||||
val item = controller.currentTrainingItem()
|
||||
val baseTitle = screenState.currentCourse?.title.orEmpty()
|
||||
.ifBlank { item?.title ?: "阅读模式" }
|
||||
val sentences = item?.sentences.orEmpty()
|
||||
titleText?.text = if (sentences.isEmpty()) {
|
||||
baseTitle
|
||||
} else {
|
||||
"$baseTitle · ${sentences.size} 句"
|
||||
}
|
||||
readingAdapter?.submitSentences(sentences)
|
||||
}
|
||||
|
||||
fun refreshTranslations() {
|
||||
readingAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun refreshFavorites() {
|
||||
readingAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun scrollToCurrentSentence() {
|
||||
val list = sentenceListView ?: return
|
||||
val currentSentence = controller.currentSentence() ?: return
|
||||
val item = controller.currentTrainingItem() ?: return
|
||||
val position = item.sentences.indexOfFirst { it.index == currentSentence.index }
|
||||
if (position < 0) return
|
||||
list.post {
|
||||
if (sentenceListView !== list) return@post
|
||||
val layoutManager = list.layoutManager as? LinearLayoutManager ?: return@post
|
||||
val offset = (list.height * 0.25f).toInt().coerceAtLeast(0)
|
||||
layoutManager.scrollToPositionWithOffset(position, offset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTranslationToggle(): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 4.dp, 0, 10.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "中文翻译"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.readingTranslationVisible
|
||||
buttonTintList = android.content.res.ColorStateList.valueOf(SampleTheme.accent)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
actions.onReadingTranslationVisible(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun translationText(sentence: SentenceBoundary): String = when {
|
||||
!sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty()
|
||||
screenState.chineseTranslationLoading -> "正在翻译中文…"
|
||||
screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError
|
||||
else -> "暂无中文翻译"
|
||||
}
|
||||
|
||||
private inner class ReadingSentenceAdapter :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var sentences: List<SentenceBoundary> = emptyList()
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return if (sentences.isEmpty()) EMPTY_ID else sentences[position].index.toLong()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (sentences.isEmpty()) TYPE_EMPTY else TYPE_SENTENCE
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == TYPE_EMPTY) {
|
||||
val empty = TextView(activity).apply {
|
||||
text = "当前课程还没有可阅读的句子文本"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}
|
||||
EmptyViewHolder(empty)
|
||||
} else {
|
||||
SentenceViewHolder(createSentenceView())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (holder !is SentenceViewHolder || sentences.isEmpty()) return
|
||||
val sentence = sentences[position]
|
||||
attachDoubleTap(holder.itemView, sentence)
|
||||
holder.text.text =
|
||||
"${sentence.index + 1}. ${sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"}"
|
||||
holder.translation.visibility = if (screenState.readingTranslationVisible) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
holder.translation.text = translationText(sentence)
|
||||
bindFavoriteButton(holder.favoriteButton, sentence)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return if (sentences.isEmpty()) 1 else sentences.size
|
||||
}
|
||||
|
||||
fun submitSentences(updated: List<SentenceBoundary>) {
|
||||
sentences = updated
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun createSentenceView(): View {
|
||||
val text = TextView(activity).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 16f
|
||||
}
|
||||
val translation = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.readingTranslation)
|
||||
textSize = 14f
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
}
|
||||
val favoriteButton = Button(activity).apply {
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(12.dp, 0, 12.dp, 0)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
32.dp,
|
||||
).withMargins(10.dp, 0, 0, 0)
|
||||
}
|
||||
val textColumn = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
addView(text)
|
||||
addView(translation)
|
||||
}
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = rounded(SampleTheme.surface, 10f, SampleTheme.border)
|
||||
setPadding(14.dp, 12.dp, 10.dp, 12.dp)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
bottomMargin = 10.dp
|
||||
}
|
||||
addView(textColumn)
|
||||
addView(favoriteButton)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindFavoriteButton(button: Button, sentence: SentenceBoundary) {
|
||||
val isFavorite = actions.isSentenceFavorite(sentence)
|
||||
button.setText(if (isFavorite) "已收藏" else "收藏")
|
||||
button.background = rounded(
|
||||
if (isFavorite) SampleTheme.accent else SampleTheme.button,
|
||||
8f,
|
||||
)
|
||||
button.setOnClickListener {
|
||||
val favorite = actions.toggleSentenceFavorite(sentence)
|
||||
button.setText(if (favorite) "已收藏" else "收藏")
|
||||
button.background = rounded(
|
||||
if (favorite) SampleTheme.accent else SampleTheme.button,
|
||||
8f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachDoubleTap(view: View, sentence: SentenceBoundary) {
|
||||
view.isClickable = true
|
||||
view.setOnClickListener {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val isDoubleTap = lastClickedSentenceIndex == sentence.index &&
|
||||
now - lastClickedAtMs <= DOUBLE_TAP_INTERVAL_MS
|
||||
if (isDoubleTap) {
|
||||
lastClickedSentenceIndex = Int.MIN_VALUE
|
||||
lastClickedAtMs = 0L
|
||||
actions.onSentenceDoubleTapped(sentence)
|
||||
} else {
|
||||
lastClickedSentenceIndex = sentence.index
|
||||
lastClickedAtMs = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class EmptyViewHolder(root: View) : RecyclerView.ViewHolder(root)
|
||||
|
||||
private inner class SentenceViewHolder(
|
||||
root: View,
|
||||
) : RecyclerView.ViewHolder(root) {
|
||||
val text: TextView
|
||||
val translation: TextView
|
||||
val favoriteButton: Button
|
||||
|
||||
init {
|
||||
val textColumn = root as ViewGroup
|
||||
val content = textColumn.getChildAt(0) as ViewGroup
|
||||
text = content.getChildAt(0) as TextView
|
||||
translation = content.getChildAt(1) as TextView
|
||||
favoriteButton = textColumn.getChildAt(1) as Button
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TYPE_EMPTY = 0
|
||||
private const val TYPE_SENTENCE = 1
|
||||
private const val EMPTY_ID = Long.MIN_VALUE
|
||||
private const val DOUBLE_TAP_INTERVAL_MS = 300L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sample.Module
|
||||
import cn.learningpad.oraltrainer.sample.ModuleTabActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
/** Shared compact widgets used across module screens. */
|
||||
internal class SampleWidgets(
|
||||
private val activity: Activity,
|
||||
private val screenState: TrainingScreenState,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun moduleTabBar(actions: ModuleTabActions): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(16.dp, 40.dp, 16.dp, 0)
|
||||
addView(moduleTab("课程", Module.COURSE, actions))
|
||||
addView(moduleTab("训练", Module.TRAIN, actions))
|
||||
addView(moduleTab("阅读", Module.READING, actions))
|
||||
addView(moduleTab("测试", Module.TEST, actions))
|
||||
addView(moduleTab("我的", Module.MINE, actions))
|
||||
}
|
||||
|
||||
fun moduleTab(label: String, module: Module, actions: ModuleTabActions): TextView {
|
||||
val selected = screenState.activeModule == module
|
||||
return TextView(activity).apply {
|
||||
text = label
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
isClickable = true
|
||||
setTextColor(if (selected) Color.WHITE else SampleTheme.textMuted)
|
||||
background = if (selected) {
|
||||
rounded(SampleTheme.button, 8f)
|
||||
} else {
|
||||
rounded(SampleTheme.surface, 8f, SampleTheme.border)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener {
|
||||
if (screenState.activeModule == module) return@setOnClickListener
|
||||
if (module != Module.COURSE && screenState.currentCourse == null) {
|
||||
Toast.makeText(activity, "请先选择课程", Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
actions.onModuleSelected(module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun headerButton(label: String, action: () -> Unit): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(14.dp, 0, 14.dp, 0)
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
40.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
|
||||
fun overlayPill(label: String): TextView =
|
||||
TextView(activity).apply {
|
||||
text = label
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
background = rounded(SampleTheme.overlayFaint, 8f)
|
||||
setPadding(10.dp, 6.dp, 10.dp, 6.dp)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
fun infoCard(text: String): TextView =
|
||||
TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
}
|
||||
|
||||
fun titleDivider(text: String): TextView =
|
||||
TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 20.dp, 0, 8.dp)
|
||||
}
|
||||
|
||||
fun courseListCard(
|
||||
video: TrainingVideoSummary,
|
||||
onClick: () -> Unit,
|
||||
): View {
|
||||
val enrolled = screenState.courseEnrollment[video.videoHash] == true
|
||||
val isSelected = screenState.currentCourse?.videoHash == video.videoHash
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
isClickable = true
|
||||
background = if (isSelected) {
|
||||
rounded(SampleTheme.selected, 10f, SampleTheme.accent)
|
||||
} else {
|
||||
rounded(SampleTheme.surface, 10f, SampleTheme.border)
|
||||
}
|
||||
setPadding(16.dp, 14.dp, 16.dp, 14.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 10.dp)
|
||||
setOnClickListener { onClick() }
|
||||
addView(TextView(activity).apply {
|
||||
text = video.title
|
||||
setTextColor(if (isSelected) SampleTheme.textDark else Color.WHITE)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 2
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = buildString {
|
||||
append(if (enrolled) "已选修" else "未选修")
|
||||
append(" · ${video.sentenceCount} 句")
|
||||
if (isSelected) append(" · 当前课程")
|
||||
}
|
||||
setTextColor(if (isSelected) SampleTheme.accentDeep else SampleTheme.textMuted)
|
||||
textSize = 13f
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TestScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class TestScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val playerChrome: PlayerChrome,
|
||||
private val actions: TestScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun attachSections(root: LinearLayout) {
|
||||
root.addView(playerChrome.createPlayerSection())
|
||||
if (screenState.testSubtitleVisible) {
|
||||
root.addView(playerChrome.createSentenceSection())
|
||||
}
|
||||
root.addView(createTestSection())
|
||||
}
|
||||
|
||||
private fun createTestSection(): View {
|
||||
bindings.testStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
bindings.scoreSummaryText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
text = "尚未评测"
|
||||
}
|
||||
bindings.scoreDetailText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
setLineSpacing(4.dp.toFloat(), 1f)
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
bindings.dubbingStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
text = "配音:尚未生成"
|
||||
}
|
||||
|
||||
fun sentenceButton(label: String, action: () -> Unit): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(0, 0, 0, 0)
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
|
||||
fun dubButton(label: String): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 8.dp, 8.dp, 0)
|
||||
}
|
||||
|
||||
bindings.recordButton = Button(activity).apply {
|
||||
text = "开始录音"
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(SampleTheme.accent, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
1f,
|
||||
)
|
||||
setOnClickListener { actions.onToggleRecording() }
|
||||
}
|
||||
|
||||
val subtitleToggle = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "显示字幕"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.testSubtitleVisible
|
||||
buttonTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
actions.onTestSubtitleChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
bindings.playDubButton = dubButton("播放配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onToggleDubPlayback() }
|
||||
}
|
||||
bindings.mergeDubButton = dubButton("生成配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onMergeDub() }
|
||||
}
|
||||
bindings.shareDubButton = dubButton("分享配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onShareDub() }
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 0, 0)
|
||||
}
|
||||
|
||||
val controlRow = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, 4.dp, 0, 8.dp)
|
||||
addView(sentenceButton("上一句") { actions.onPrevSentence() })
|
||||
addView(sentenceButton("重播本句") { actions.onReplaySentence() })
|
||||
addView(
|
||||
sentenceButton("下一句") { actions.onNextSentence() }.apply {
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 0, 0, 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(Color.WHITE, 8f, SampleTheme.lightBorder)
|
||||
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(16.dp, 0, 16.dp, 12.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "朗读评测(测试)"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
})
|
||||
addView(subtitleToggle, 0)
|
||||
addView(bindings.testStatusText)
|
||||
addView(controlRow)
|
||||
addView(bindings.recordButton)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(bindings.playDubButton)
|
||||
addView(bindings.mergeDubButton)
|
||||
addView(bindings.shareDubButton)
|
||||
})
|
||||
addView(bindings.scoreSummaryText)
|
||||
addView(bindings.scoreDetailText)
|
||||
addView(bindings.dubbingStatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class TrainScreen(
|
||||
private val activity: Activity,
|
||||
private val playerChrome: PlayerChrome,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun attachPortraitSections(root: LinearLayout) {
|
||||
root.addView(createHeader())
|
||||
root.addView(playerChrome.createPlayerSection())
|
||||
root.addView(playerChrome.createSentenceSection())
|
||||
}
|
||||
|
||||
private fun createHeader(): View {
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(18.dp, 16.dp, 18.dp, 12.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "八哥口语"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
includeFontPadding = false
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "外语跟读训练神器"
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
textSize = 18f
|
||||
includeFontPadding = false
|
||||
setPadding(12.dp, 6.dp, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
14
android/sample-app/src/main/res/drawable/ic_ai_explain.xml
Normal file
14
android/sample-app/src/main/res/drawable/ic_ai_explain.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M24,4a13,13 0 0 0 -7.2,23.8c0.7,0.5 1.2,1.3 1.2,2.2v1.5h12v-1.5c0,-0.9 0.5,-1.7 1.2,-2.2A13,13 0 0 0 24,4zM18,36h12v3h-12zM20,42h8v2h-8z"/>
|
||||
<path
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M38,10l1.8,4.2L44,16l-4.2,1.8L38,22l-1.8,-4.2L32,16l4.2,-1.8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -1,4 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">八哥口语</string>
|
||||
<string name="training_playback_channel_name">训练播放</string>
|
||||
<string name="training_playback_notification_title">口语训练</string>
|
||||
<string name="training_playback_notification_text">正在后台播放</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">MynaTalk</string>
|
||||
<string name="training_playback_channel_name">Training playback</string>
|
||||
<string name="training_playback_notification_title">Oral training</string>
|
||||
<string name="training_playback_notification_text">Playing in the background</string>
|
||||
</resources>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; };
|
||||
AA00000000000000000000A1 /* UserHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A2 /* UserHistory.swift */; };
|
||||
AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A4 /* TrainingHistoryStore.swift */; };
|
||||
AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SentenceExplanationStore.swift */; };
|
||||
AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; };
|
||||
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
||||
@@ -29,9 +34,16 @@
|
||||
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; };
|
||||
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
|
||||
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
|
||||
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; };
|
||||
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A6 /* StreamingVideoCache.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000B1 /* SentenceExplanationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceExplanationStore.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -56,6 +68,8 @@
|
||||
AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000A6 /* StreamingVideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingVideoCache.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -115,9 +129,15 @@
|
||||
children = (
|
||||
AA0000000000000000000028 /* HTTPClient.swift */,
|
||||
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
||||
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||
AA00000000000000000000A4 /* TrainingHistoryStore.swift */,
|
||||
AA00000000000000000000B1 /* SentenceExplanationStore.swift */,
|
||||
AA00000000000000000000A2 /* UserHistory.swift */,
|
||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
||||
AA00000000000000000000A6 /* StreamingVideoCache.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -136,6 +156,7 @@
|
||||
children = (
|
||||
AA000000000000000000002E /* Theme.swift */,
|
||||
AA000000000000000000002F /* RootView.swift */,
|
||||
AA0000000000000000000093 /* AuthView.swift */,
|
||||
AA0000000000000000000030 /* TrainingView.swift */,
|
||||
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
||||
AA0000000000000000000032 /* CatalogView.swift */,
|
||||
@@ -237,6 +258,8 @@
|
||||
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */,
|
||||
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
|
||||
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
|
||||
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
|
||||
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */,
|
||||
AA000000000000000000004C /* PlayerController.swift in Sources */,
|
||||
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
|
||||
AA000000000000000000004E /* Theme.swift in Sources */,
|
||||
@@ -244,6 +267,11 @@
|
||||
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
||||
AA00000000000000000000A1 /* UserHistory.swift in Sources */,
|
||||
AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */,
|
||||
AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */,
|
||||
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
||||
AA0000000000000000000053 /* TestView.swift in Sources */,
|
||||
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<array/>
|
||||
</plist>
|
||||
Binary file not shown.
@@ -9,4 +9,38 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
) -> UIInterfaceOrientationMask {
|
||||
AppDelegate.orientationLock
|
||||
}
|
||||
|
||||
static func lockOrientation(_ mask: UIInterfaceOrientationMask) {
|
||||
orientationLock = mask
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive })
|
||||
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
func update(_ controller: UIViewController?) {
|
||||
guard let controller else { return }
|
||||
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
update(controller.presentedViewController)
|
||||
controller.children.forEach { update($0) }
|
||||
}
|
||||
|
||||
let requested: UIInterfaceOrientationMask
|
||||
if mask.contains(.landscapeRight) {
|
||||
requested = .landscapeRight
|
||||
} else if mask.contains(.landscapeLeft) {
|
||||
requested = .landscapeLeft
|
||||
} else {
|
||||
requested = mask
|
||||
}
|
||||
|
||||
scene.windows.forEach { update($0.rootViewController) }
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: requested)) { _ in }
|
||||
DispatchQueue.main.async {
|
||||
scene.windows.forEach { update($0.rootViewController) }
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: requested)) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ enum AppConfig {
|
||||
static let connectTimeout: TimeInterval = 15
|
||||
static let readTimeout: TimeInterval = 30
|
||||
static let assessmentTimeout: TimeInterval = 180
|
||||
static let translationTimeout: TimeInterval = 30
|
||||
static let translationDeadline: TimeInterval = 900
|
||||
static let translationPollIntervalNs: UInt64 = 1_500_000_000
|
||||
|
||||
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
||||
|
||||
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
|
||||
static var authToken: String = ""
|
||||
|
||||
/// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。
|
||||
static var currentUserId: String = ""
|
||||
}
|
||||
|
||||
@@ -50,9 +50,35 @@ enum GestureKind {
|
||||
case longPressSpeed
|
||||
}
|
||||
|
||||
enum SwipeBackResult {
|
||||
case restartedCurrent
|
||||
case movedPrevious
|
||||
case stayedAtFirst
|
||||
case rewound
|
||||
}
|
||||
|
||||
enum BoundaryState: Equatable {
|
||||
case idle
|
||||
case loading(isLocal: Bool)
|
||||
case loaded(count: Int)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// 云端视频按需流式播放;若本地已有完整缓存文件则优先使用。
|
||||
enum VideoCacheState: Equatable {
|
||||
case notRequired
|
||||
case downloading
|
||||
case ready
|
||||
case failed(message: String)
|
||||
|
||||
var statusMessage: String? {
|
||||
switch self {
|
||||
case .notRequired, .ready:
|
||||
return nil
|
||||
case .downloading:
|
||||
return "正在缓冲视频…"
|
||||
case .failed(let message):
|
||||
return "视频加载失败:\(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
|
||||
let text: String?
|
||||
let language: String?
|
||||
let referenceSpeechDurationMs: Int64?
|
||||
let zhText: String?
|
||||
|
||||
var id: Int { index }
|
||||
|
||||
@@ -15,6 +16,48 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
|
||||
case startMs = "start_ms"
|
||||
case endMs = "end_ms"
|
||||
case referenceSpeechDurationMs = "reference_speech_duration_ms"
|
||||
case zhText = "zh_text"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
index = try container.decode(Int.self, forKey: .index)
|
||||
startMs = try container.decode(Int64.self, forKey: .startMs)
|
||||
endMs = try container.decode(Int64.self, forKey: .endMs)
|
||||
text = try container.decodeIfPresent(String.self, forKey: .text)
|
||||
language = try container.decodeIfPresent(String.self, forKey: .language)
|
||||
referenceSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceSpeechDurationMs)
|
||||
zhText = try container.decodeIfPresent(String.self, forKey: .zhText)
|
||||
}
|
||||
|
||||
init(
|
||||
index: Int,
|
||||
startMs: Int64,
|
||||
endMs: Int64,
|
||||
text: String?,
|
||||
language: String?,
|
||||
referenceSpeechDurationMs: Int64?,
|
||||
zhText: String? = nil
|
||||
) {
|
||||
self.index = index
|
||||
self.startMs = startMs
|
||||
self.endMs = endMs
|
||||
self.text = text
|
||||
self.language = language
|
||||
self.referenceSpeechDurationMs = referenceSpeechDurationMs
|
||||
self.zhText = zhText
|
||||
}
|
||||
|
||||
func withZhText(_ zhText: String?) -> SentenceBoundary {
|
||||
SentenceBoundary(
|
||||
index: index,
|
||||
startMs: startMs,
|
||||
endMs: endMs,
|
||||
text: text,
|
||||
language: language,
|
||||
referenceSpeechDurationMs: referenceSpeechDurationMs,
|
||||
zhText: zhText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +109,19 @@ struct TrainingVideoSummary: Identifiable, Decodable {
|
||||
}
|
||||
return URL(string: absolute)
|
||||
}
|
||||
|
||||
func copy(durationMs: Int64? = self.durationMs, sentenceCount: Int = self.sentenceCount) -> Self {
|
||||
TrainingVideoSummary(
|
||||
videoHash: videoHash,
|
||||
title: title,
|
||||
streamUrl: streamUrl,
|
||||
durationMs: durationMs,
|
||||
sizeBytes: sizeBytes,
|
||||
language: language,
|
||||
sentenceCount: sentenceCount,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TrainingMediaItem: Identifiable, Equatable {
|
||||
|
||||
@@ -10,6 +10,7 @@ final class PlayerController: ObservableObject {
|
||||
@Published private(set) var currentSentence: SentenceBoundary?
|
||||
@Published var boundaryState: BoundaryState = .idle
|
||||
@Published var transientMessage: String?
|
||||
@Published private(set) var cacheState: VideoCacheState = .notRequired
|
||||
|
||||
let player = AVPlayer()
|
||||
|
||||
@@ -18,10 +19,30 @@ final class PlayerController: ObservableObject {
|
||||
|
||||
private var mediaItem: TrainingMediaItem?
|
||||
private var lastSentenceIndex: Int?
|
||||
private var pendingSeekPositionMs: Int64?
|
||||
private var seekGeneration = 0
|
||||
/// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在
|
||||
/// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断
|
||||
/// 用户是否希望继续播放。
|
||||
private var playbackIntent = false
|
||||
private var stopAtSentenceEnd = false
|
||||
private var stopAtSentenceEndIndex: Int?
|
||||
private var swipeBackAnchorIndex: Int?
|
||||
private var swipeBackAnchorTime: TimeInterval = 0
|
||||
private var speedBeforeLongPress: Float?
|
||||
private var transientTask: Task<Void, Never>?
|
||||
private var cacheFallbackTask: Task<Void, Never>?
|
||||
private var activePlaybackURL: URL?
|
||||
private var didAttemptFullFileFallback = false
|
||||
private var sentenceEndTimeObserver: Any?
|
||||
private var sentenceEndGuardToken = 0
|
||||
|
||||
private static let swipeBackPhaseTimeout: TimeInterval = 1.2
|
||||
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
|
||||
/// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。
|
||||
private static let sentenceSeekInsetMs: Int64 = 20
|
||||
private static let previousSentenceToleranceMs: Int64 = 250
|
||||
private static let nextSentenceToleranceMs: Int64 = 150
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: NSObjectProtocol?
|
||||
@@ -31,6 +52,7 @@ final class PlayerController: ObservableObject {
|
||||
init() {
|
||||
configureAudioSession()
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
player.actionAtItemEnd = .pause
|
||||
timeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
|
||||
queue: .main
|
||||
@@ -46,6 +68,7 @@ final class PlayerController: ObservableObject {
|
||||
}
|
||||
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshSentenceBoundaryGuard()
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
@@ -59,37 +82,76 @@ final class PlayerController: ObservableObject {
|
||||
if let endObserver = endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
cancelSentenceBoundaryGuard()
|
||||
itemStatusObserver?.invalidate()
|
||||
timeControlObserver?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - 装载
|
||||
|
||||
@MainActor
|
||||
func load(_ item: TrainingMediaItem) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
cacheFallbackTask?.cancel()
|
||||
cacheFallbackTask = nil
|
||||
didAttemptFullFileFallback = false
|
||||
lastSentenceIndex = nil
|
||||
swipeBackAnchorIndex = nil
|
||||
pendingSeekPositionMs = nil
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
playbackIntent = false
|
||||
mediaItem = item
|
||||
currentItem = item
|
||||
boundaryState = .idle
|
||||
let playerItem = AVPlayerItem(url: item.url)
|
||||
attachItemObservers(to: playerItem)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
if config.autoPlay {
|
||||
player.play()
|
||||
|
||||
if item.isLocalFile {
|
||||
cacheState = .notRequired
|
||||
preparePlaybackItem(with: item.url)
|
||||
} else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) {
|
||||
cacheState = .ready
|
||||
preparePlaybackItem(with: cachedURL)
|
||||
} else {
|
||||
cacheState = .notRequired
|
||||
preparePlaybackItem(with: item.url)
|
||||
}
|
||||
}
|
||||
|
||||
/// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。
|
||||
/// 否则会把 AVFoundation 已经缓存的媒体数据丢掉,回跳上一句时可能重新联网。
|
||||
@MainActor
|
||||
func updateBoundaries(_ sentences: [SentenceBoundary]) {
|
||||
guard let oldItem = mediaItem else {
|
||||
return
|
||||
}
|
||||
let newItem = TrainingMediaItem(
|
||||
id: oldItem.id,
|
||||
title: oldItem.title,
|
||||
url: oldItem.url,
|
||||
sentences: sentences,
|
||||
isLocalFile: oldItem.isLocalFile
|
||||
)
|
||||
mediaItem = newItem
|
||||
currentItem = newItem
|
||||
lastSentenceIndex = nil
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - 播放控制
|
||||
|
||||
func play() {
|
||||
playbackIntent = true
|
||||
player.play()
|
||||
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
|
||||
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
|
||||
refreshSentenceBoundaryGuard(force: true)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
playbackIntent = false
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.pause()
|
||||
}
|
||||
|
||||
@@ -108,19 +170,20 @@ final class PlayerController: ObservableObject {
|
||||
func stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
playbackIntent = false
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.pause()
|
||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
seek(to: .zero)
|
||||
}
|
||||
|
||||
func seek(to positionMs: Int64) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
performSeek(to: positionMs)
|
||||
}
|
||||
|
||||
func seekRelative(_ deltaMs: Int64) {
|
||||
seek(to: snapshot.positionMs + deltaMs)
|
||||
seek(to: referencePositionMs() + deltaMs)
|
||||
}
|
||||
|
||||
func rewind() {
|
||||
@@ -138,12 +201,14 @@ final class PlayerController: ObservableObject {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||
return false
|
||||
}
|
||||
let position = snapshot.positionMs
|
||||
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
|
||||
?? item.sentences.first else {
|
||||
return false
|
||||
}
|
||||
seek(to: target.startMs)
|
||||
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
|
||||
// 播放点落在两句之间的空隙时也能正确定位前一句。
|
||||
let position = referencePositionMs()
|
||||
let target = item.sentences
|
||||
.reversed()
|
||||
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
|
||||
?? item.sentences[0]
|
||||
seekToSentenceBeginning(target)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -152,14 +217,49 @@ final class PlayerController: ObservableObject {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||
return false
|
||||
}
|
||||
let position = snapshot.positionMs
|
||||
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
|
||||
// Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间
|
||||
// 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。
|
||||
let position = referencePositionMs()
|
||||
guard let target = item.sentences.first(where: {
|
||||
$0.startMs > position + Self.nextSentenceToleranceMs
|
||||
}) else {
|
||||
return false
|
||||
}
|
||||
seek(to: target.startMs)
|
||||
seekToSentenceBeginning(target)
|
||||
return true
|
||||
}
|
||||
|
||||
/// 按索引跳转,而不是每次用异步的 AVPlayer 时间重新搜索。
|
||||
/// 这样一次手势只前进/后退一句,也不会因 seek 后时间上报偏差导致重复或无效。
|
||||
@discardableResult
|
||||
func seekToSentence(at index: Int) -> Bool {
|
||||
guard let item = mediaItem, item.sentences.indices.contains(index) else {
|
||||
return false
|
||||
}
|
||||
seekToSentenceBeginning(item.sentences[index])
|
||||
return true
|
||||
}
|
||||
|
||||
private func sentenceAnchorIndex() -> Int? {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else { return nil }
|
||||
if let index = currentSentence?.index, item.sentences.indices.contains(index) {
|
||||
return index
|
||||
}
|
||||
if let index = lastSentenceIndex, item.sentences.indices.contains(index) {
|
||||
return index
|
||||
}
|
||||
|
||||
let position = referencePositionMs()
|
||||
if let sentence = sentence(at: position) {
|
||||
return sentence.index
|
||||
}
|
||||
// 句间隙中时,取前一句作为锚点;在第一句之前时取下一句。
|
||||
if let previous = item.sentences.last(where: { $0.startMs <= position }) {
|
||||
return previous.index
|
||||
}
|
||||
return item.sentences.first?.index
|
||||
}
|
||||
|
||||
func previousSentenceOrRewind() {
|
||||
if !config.sentenceMode || !seekToPreviousSentence() {
|
||||
rewind()
|
||||
@@ -172,26 +272,76 @@ final class PlayerController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放中向左划:第一次回到当前句开头重播;短时间内再划一次才到上一句。
|
||||
/// 暂停时保持原来的“上一句”语义,测试页“上一句”按钮也不受影响。
|
||||
@discardableResult
|
||||
func replayOrPreviousSentence() -> SwipeBackResult {
|
||||
guard config.sentenceMode,
|
||||
let item = mediaItem,
|
||||
!item.sentences.isEmpty,
|
||||
let anchorIndex = sentenceAnchorIndex() else {
|
||||
rewind()
|
||||
return .rewound
|
||||
}
|
||||
|
||||
let isActivelyPlaying = player.timeControlStatus == .playing
|
||||
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
|
||||
guard isActivelyPlaying else {
|
||||
swipeBackAnchorIndex = nil
|
||||
return seekToPreviousSentence() ? .movedPrevious : .stayedAtFirst
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
if swipeBackAnchorIndex == anchorIndex,
|
||||
now - swipeBackAnchorTime <= Self.swipeBackPhaseTimeout {
|
||||
swipeBackAnchorIndex = nil
|
||||
guard anchorIndex > 0 else {
|
||||
seekToSentenceBeginning(item.sentences[anchorIndex])
|
||||
return .stayedAtFirst
|
||||
}
|
||||
seekToSentenceBeginning(item.sentences[anchorIndex - 1])
|
||||
return .movedPrevious
|
||||
}
|
||||
|
||||
swipeBackAnchorIndex = anchorIndex
|
||||
swipeBackAnchorTime = now
|
||||
let current = item.sentences[anchorIndex]
|
||||
if stopAtSentenceEnd {
|
||||
playCurrentSentenceAndStop()
|
||||
} else {
|
||||
seekToSentenceBeginning(current)
|
||||
play()
|
||||
}
|
||||
return .restartedCurrent
|
||||
}
|
||||
|
||||
/// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。
|
||||
/// 必须先打上句尾标记再 seek,这样 completion 里的 forwardPlaybackEndTime
|
||||
/// 会卡在当前句 endMs,而不是等 250ms 观察器才停。
|
||||
func playCurrentSentenceAndStop() {
|
||||
guard let sentence = currentSentence else {
|
||||
return
|
||||
}
|
||||
let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
stopAtSentenceEnd = true
|
||||
stopAtSentenceEndIndex = sentence.index
|
||||
play()
|
||||
lastSentenceIndex = sentence.index
|
||||
playbackIntent = true
|
||||
performSeek(to: sentenceSeekPosition(sentence), resumePlayback: true)
|
||||
}
|
||||
|
||||
// MARK: - 设置
|
||||
|
||||
func setContinuousPlayback(_ enabled: Bool) {
|
||||
config.continuousPlayback = enabled
|
||||
if !enabled {
|
||||
lastSentenceIndex = currentSentence?.index ?? lastSentenceIndex
|
||||
}
|
||||
refreshSentenceBoundaryGuard(force: true)
|
||||
}
|
||||
|
||||
func setSentenceMode(_ enabled: Bool) {
|
||||
config.sentenceMode = enabled
|
||||
refreshSentenceBoundaryGuard()
|
||||
}
|
||||
|
||||
func setLoopMode(_ mode: LoopMode) {
|
||||
@@ -248,8 +398,16 @@ final class PlayerController: ObservableObject {
|
||||
togglePlayPause()
|
||||
showTransientMessage("播放状态已切换")
|
||||
case .swipeLeft:
|
||||
previousSentenceOrRewind()
|
||||
showTransientMessage("已跳到上一句")
|
||||
switch replayOrPreviousSentence() {
|
||||
case .restartedCurrent:
|
||||
showTransientMessage("已回到本句开头,再左划返回上一句")
|
||||
case .movedPrevious:
|
||||
showTransientMessage("已跳到上一句")
|
||||
case .stayedAtFirst:
|
||||
showTransientMessage("已是第一句")
|
||||
case .rewound:
|
||||
showTransientMessage("已快退")
|
||||
}
|
||||
case .swipeRight:
|
||||
nextSentenceOrForward()
|
||||
showTransientMessage("已跳到下一句")
|
||||
@@ -277,13 +435,21 @@ final class PlayerController: ObservableObject {
|
||||
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
|
||||
return
|
||||
}
|
||||
let position = currentPositionMs()
|
||||
enforceSentenceBoundary(positionMs: position)
|
||||
let finalPosition = currentPositionMs()
|
||||
let position = referencePositionMs()
|
||||
if pendingSeekPositionMs == nil, lastSentenceIndex == nil {
|
||||
lastSentenceIndex = sentence(at: position)?.index
|
||||
}
|
||||
if pendingSeekPositionMs == nil {
|
||||
enforceSentenceBoundary(positionMs: position)
|
||||
}
|
||||
let finalPosition = referencePositionMs()
|
||||
let sentence = sentence(at: finalPosition)
|
||||
if sentence?.index != lastSentenceIndex {
|
||||
lastSentenceIndex = sentence?.index
|
||||
}
|
||||
if sentence?.index != swipeBackAnchorIndex {
|
||||
swipeBackAnchorIndex = nil
|
||||
}
|
||||
let duration = durationMs(of: item)
|
||||
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
|
||||
snapshot = PlaybackSnapshot(
|
||||
@@ -307,6 +473,77 @@ final class PlayerController: ObservableObject {
|
||||
return max(0, Int64(seconds * 1000))
|
||||
}
|
||||
|
||||
/// 连续手势/快进快退时使用 pending seek 的目标位置,避免仍基于旧的播放位置计算。
|
||||
private func referencePositionMs() -> Int64 {
|
||||
pendingSeekPositionMs ?? currentPositionMs()
|
||||
}
|
||||
|
||||
private func preparePlaybackItem(with playbackURL: URL) {
|
||||
let newItem = makePlayerItem(url: playbackURL)
|
||||
activePlaybackURL = playbackURL
|
||||
attachItemObservers(to: newItem)
|
||||
player.replaceCurrentItem(with: newItem)
|
||||
let shouldPlay = config.autoPlay || playbackIntent
|
||||
if shouldPlay {
|
||||
player.play()
|
||||
}
|
||||
playbackIntent = shouldPlay
|
||||
tick()
|
||||
}
|
||||
|
||||
private func makePlayerItem(url: URL) -> AVPlayerItem {
|
||||
guard !url.isFileURL else {
|
||||
return AVPlayerItem(url: url)
|
||||
}
|
||||
var options: [String: Any] = [:]
|
||||
let ext = url.pathExtension.lowercased()
|
||||
if ext.isEmpty || ["mp4", "m4v", "mov"].contains(ext) {
|
||||
options["AVURLAssetOverrideMIMETypeKey"] = "video/mp4"
|
||||
}
|
||||
let asset = AVURLAsset(url: url, options: options.isEmpty ? nil : options)
|
||||
return AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: ["playable", "tracks", "duration"])
|
||||
}
|
||||
|
||||
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
||||
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
|
||||
private func performSeek(
|
||||
to positionMs: Int64,
|
||||
resumePlayback: Bool? = nil,
|
||||
completion: (() -> Void)? = nil
|
||||
) {
|
||||
let targetPositionMs = max(0, positionMs)
|
||||
let shouldResumePlayback = resumePlayback ?? playbackIntent
|
||||
// 必须在发起 seek 前清掉上一句的硬边界。否则从第 1 句右划到第 2 句时,
|
||||
// AVPlayerItem 上的 forwardPlaybackEndTime 仍是第 1 句 endMs;seek 可能
|
||||
// 被这个旧边界截断,UI 先显示第 2 句,然后立刻又回到第 1 句。
|
||||
cancelSentenceBoundaryGuard()
|
||||
clearForwardPlaybackEnd()
|
||||
seekGeneration += 1
|
||||
let generation = seekGeneration
|
||||
pendingSeekPositionMs = targetPositionMs
|
||||
// 立即切换句子锚点。否则 periodic observer 仍可能以旧句子为锚点,
|
||||
// 在 seek 完成前后把新位置拉回旧句开头。
|
||||
lastSentenceIndex = sentence(at: targetPositionMs)?.index
|
||||
let time = CMTime(seconds: Double(targetPositionMs) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.seekGeneration == generation else {
|
||||
return
|
||||
}
|
||||
self.pendingSeekPositionMs = nil
|
||||
self.tick()
|
||||
if shouldResumePlayback {
|
||||
self.player.play()
|
||||
}
|
||||
completion?()
|
||||
// seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。
|
||||
self.refreshSentenceBoundaryGuard(force: shouldResumePlayback)
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
private func durationMs(of item: AVPlayerItem) -> Int64 {
|
||||
let seconds = item.duration.seconds
|
||||
guard seconds.isFinite, seconds > 0 else {
|
||||
@@ -388,13 +625,137 @@ final class PlayerController: ObservableObject {
|
||||
return
|
||||
}
|
||||
if positionMs >= anchor.endMs {
|
||||
seekRaw(anchor.startMs)
|
||||
seekRaw(sentenceSeekPosition(anchor))
|
||||
}
|
||||
}
|
||||
|
||||
private func seekRaw(_ positionMs: Int64) {
|
||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) {
|
||||
performSeek(to: positionMs, completion: completion)
|
||||
}
|
||||
|
||||
/// AVPlayer 的 periodic observer 间隔是 250ms。仅靠它回收句尾时,
|
||||
/// 最坏情况已经播进下一句约 250ms。这里参照安卓端 PlayerMessage 的做法,
|
||||
/// 在当前句 endMs 精确注册 boundary observer,到点立即执行句尾动作。
|
||||
private func refreshSentenceBoundaryGuard(force: Bool = false) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
guard config.sentenceMode,
|
||||
let item = mediaItem,
|
||||
!item.sentences.isEmpty else {
|
||||
clearForwardPlaybackEnd()
|
||||
return
|
||||
}
|
||||
guard force || isActivelyPlaying() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let sentence = sentenceBoundaryTarget(in: item) else {
|
||||
clearForwardPlaybackEnd()
|
||||
return
|
||||
}
|
||||
let currentPosition = referencePositionMs()
|
||||
if currentPosition >= sentence.endMs {
|
||||
applySentenceEndAction(sentence)
|
||||
return
|
||||
}
|
||||
|
||||
let token = sentenceEndGuardToken
|
||||
let endTime = CMTime(value: CMTimeValue(sentence.endMs), timescale: 1000)
|
||||
// boundary observer 负责回到句首;forwardPlaybackEndTime 是解码层硬边界,
|
||||
// 即使主线程回调稍晚,AVPlayer 也不会继续渲染 endMs 之后的内容。
|
||||
player.currentItem?.forwardPlaybackEndTime = endTime
|
||||
sentenceEndTimeObserver = player.addBoundaryTimeObserver(
|
||||
forTimes: [NSValue(time: endTime)],
|
||||
queue: .main
|
||||
) { [weak self] in
|
||||
guard let self, token == self.sentenceEndGuardToken else {
|
||||
return
|
||||
}
|
||||
self.applySentenceEndAction(sentence)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSentenceBoundaryGuard() {
|
||||
sentenceEndGuardToken += 1
|
||||
if let observer = sentenceEndTimeObserver {
|
||||
player.removeTimeObserver(observer)
|
||||
}
|
||||
sentenceEndTimeObserver = nil
|
||||
}
|
||||
|
||||
private func clearForwardPlaybackEnd() {
|
||||
player.currentItem?.forwardPlaybackEndTime = .invalid
|
||||
}
|
||||
|
||||
private func isActivelyPlaying() -> Bool {
|
||||
player.timeControlStatus == .playing
|
||||
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
|
||||
}
|
||||
|
||||
private func sentenceBoundaryTarget(in item: TrainingMediaItem) -> SentenceBoundary? {
|
||||
if stopAtSentenceEnd {
|
||||
guard let index = stopAtSentenceEndIndex else {
|
||||
return nil
|
||||
}
|
||||
return item.sentences.first(where: { $0.index == index })
|
||||
}
|
||||
if config.continuousPlayback {
|
||||
return nil
|
||||
}
|
||||
if let index = lastSentenceIndex ?? currentSentence?.index,
|
||||
let sentence = item.sentences.first(where: { $0.index == index }) {
|
||||
return sentence
|
||||
}
|
||||
|
||||
let position = referencePositionMs()
|
||||
if let sentence = sentence(at: position) {
|
||||
return sentence
|
||||
}
|
||||
return item.sentences.last(where: { $0.startMs <= position })
|
||||
}
|
||||
|
||||
private func applySentenceEndAction(_ target: SentenceBoundary) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
guard let item = mediaItem,
|
||||
let sentence = item.sentences.first(where: { $0.index == target.index }) else {
|
||||
return
|
||||
}
|
||||
lastSentenceIndex = sentence.index
|
||||
|
||||
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
playbackIntent = false
|
||||
player.pause()
|
||||
seekRaw(sentenceSeekPosition(sentence))
|
||||
tick()
|
||||
return
|
||||
}
|
||||
if config.continuousPlayback {
|
||||
clearForwardPlaybackEnd()
|
||||
return
|
||||
}
|
||||
|
||||
// forwardPlaybackEndTime 到点后 AVPlayer 会自动暂停。这里先回到句首,
|
||||
// 等 seek 完成后再恢复播放,实现同一句循环且不混入下一句内容。
|
||||
player.pause()
|
||||
seekRaw(sentenceSeekPosition(sentence)) { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.player.play()
|
||||
self.refreshSentenceBoundaryGuard(force: true)
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
|
||||
private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 {
|
||||
let available = max(0, sentence.endMs - sentence.startMs - 1)
|
||||
let inset = min(Self.sentenceSeekInsetMs, available)
|
||||
return sentence.startMs + inset
|
||||
}
|
||||
|
||||
private func seekToSentenceBeginning(_ sentence: SentenceBoundary) {
|
||||
seek(to: sentenceSeekPosition(sentence))
|
||||
}
|
||||
|
||||
private func handleEnded() {
|
||||
@@ -402,11 +763,55 @@ final class PlayerController: ObservableObject {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
player.play()
|
||||
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
|
||||
// 不能再被识别成整条视频结束。
|
||||
guard pendingSeekPositionMs == nil else {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
// 评测“播放原句”即使处于连续播放,也要用当前句硬边界,不能滑进下一句。
|
||||
if stopAtSentenceEnd,
|
||||
let index = stopAtSentenceEndIndex,
|
||||
let sentence = mediaItem?.sentences.first(where: { $0.index == index }) {
|
||||
applySentenceEndAction(sentence)
|
||||
return
|
||||
}
|
||||
// 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束
|
||||
// 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。
|
||||
if config.sentenceMode,
|
||||
!config.continuousPlayback,
|
||||
let sentence = currentSentence,
|
||||
currentPositionMs() + 30 >= sentence.endMs {
|
||||
applySentenceEndAction(sentence)
|
||||
return
|
||||
}
|
||||
// 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。
|
||||
// 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。
|
||||
guard isNaturalMediaEnd() else {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
// 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停,
|
||||
// 则不应因为到达 media end 而突然自动循环。
|
||||
performSeek(to: .zero)
|
||||
tick()
|
||||
}
|
||||
|
||||
private func isNaturalMediaEnd() -> Bool {
|
||||
guard let item = player.currentItem else {
|
||||
return true
|
||||
}
|
||||
let durationMs = durationMs(of: item)
|
||||
let positionMs = currentPositionMs()
|
||||
if durationMs > 0 {
|
||||
return positionMs + 30 >= durationMs
|
||||
}
|
||||
guard let sentence = currentSentence else {
|
||||
return true
|
||||
}
|
||||
return positionMs + 30 >= sentence.endMs
|
||||
}
|
||||
|
||||
// MARK: - 观察器
|
||||
|
||||
private func attachItemObservers(to item: AVPlayerItem) {
|
||||
@@ -419,12 +824,57 @@ final class PlayerController: ObservableObject {
|
||||
if item.status == .failed {
|
||||
let message = item.error?.localizedDescription ?? "未知错误"
|
||||
self.showTransientMessage("播放失败:\(message)")
|
||||
} else if item.status == .readyToPlay {
|
||||
self.considerFullFileFallback(for: item)
|
||||
}
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func considerFullFileFallback(for item: AVPlayerItem) {
|
||||
guard !didAttemptFullFileFallback,
|
||||
let media = mediaItem,
|
||||
!media.isLocalFile,
|
||||
!(activePlaybackURL?.isFileURL ?? false) else {
|
||||
return
|
||||
}
|
||||
let hasVisibleVideo = item.presentationSize.height >= 2 && item.presentationSize.width >= 2
|
||||
if hasVisibleVideo {
|
||||
return
|
||||
}
|
||||
didAttemptFullFileFallback = true
|
||||
cacheFallbackTask?.cancel()
|
||||
cacheFallbackTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
guard let self, !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
guard self.mediaItem?.id == media.id,
|
||||
self.player.currentItem === item,
|
||||
item.presentationSize.height < 2 else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let cachedURL = try await StreamingVideoCache.shared.prefetch(media.url)
|
||||
guard self.mediaItem?.id == media.id else {
|
||||
return
|
||||
}
|
||||
let shouldPlay = self.playbackIntent
|
||||
let positionMs = self.pendingSeekPositionMs ?? self.currentPositionMs()
|
||||
self.preparePlaybackItem(with: cachedURL)
|
||||
self.cacheState = .ready
|
||||
if positionMs > 0 {
|
||||
self.performSeek(to: positionMs, resumePlayback: shouldPlay)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
self.showTransientMessage("视频画面加载失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, mode: .moviePlayback)
|
||||
|
||||
@@ -5,32 +5,38 @@ import UIKit
|
||||
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
||||
/// - 单击:播放 / 暂停
|
||||
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
||||
/// - 上划 / 下划:显示 / 隐藏字幕层(由 SwiftUI 横屏页接收)
|
||||
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
||||
struct PlayerSurfaceView: UIViewRepresentable {
|
||||
let controller: PlayerController
|
||||
var onVerticalSwipe: ((Bool) -> Void)?
|
||||
|
||||
func makeUIView(context: Context) -> VideoSurfaceView {
|
||||
let view = VideoSurfaceView()
|
||||
view.playerLayer.player = controller.player
|
||||
view.controller = controller
|
||||
view.onVerticalSwipe = onVerticalSwipe
|
||||
view.attach(player: controller.player)
|
||||
view.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
view.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
||||
uiView.playerLayer.player = controller.player
|
||||
uiView.controller = controller
|
||||
uiView.onVerticalSwipe = onVerticalSwipe
|
||||
uiView.attach(player: controller.player)
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoSurfaceView: UIView {
|
||||
weak var controller: PlayerController?
|
||||
var onVerticalSwipe: ((Bool) -> Void)?
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
AVPlayerLayer.self
|
||||
}
|
||||
private let videoLayer = AVPlayerLayer()
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
var playerLayer: AVPlayerLayer { videoLayer }
|
||||
|
||||
private let minSwipeDistance: CGFloat = 48
|
||||
private let minSwipeVelocity: CGFloat = 160
|
||||
@@ -39,11 +45,22 @@ final class VideoSurfaceView: UIView {
|
||||
private var tapRecognizer: UITapGestureRecognizer!
|
||||
private var panRecognizer: UIPanGestureRecognizer!
|
||||
private var longPressRecognizer: UILongPressGestureRecognizer!
|
||||
private var didDispatchSwipe = false
|
||||
private var swipeAxis: SwipeAxis?
|
||||
|
||||
private enum SwipeAxis {
|
||||
case horizontal
|
||||
case vertical
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .black
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
isOpaque = true
|
||||
clipsToBounds = true
|
||||
videoLayer.videoGravity = .resizeAspect
|
||||
videoLayer.backgroundColor = UIColor.black.cgColor
|
||||
layer.addSublayer(videoLayer)
|
||||
|
||||
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
@@ -70,24 +87,87 @@ final class VideoSurfaceView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
videoLayer.frame = bounds
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
func attach(player: AVPlayer) {
|
||||
if videoLayer.player !== player {
|
||||
videoLayer.player = player
|
||||
}
|
||||
if videoLayer.superlayer !== layer {
|
||||
layer.addSublayer(videoLayer)
|
||||
}
|
||||
videoLayer.frame = bounds
|
||||
videoLayer.isHidden = false
|
||||
}
|
||||
|
||||
@objc private func handleTap() {
|
||||
controller?.handleGesture(.singleTap)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard recognizer.state == .ended || recognizer.state == .cancelled else {
|
||||
return
|
||||
}
|
||||
let translation = recognizer.translation(in: self)
|
||||
let velocity = recognizer.velocity(in: self)
|
||||
let dx = translation.x
|
||||
let dy = translation.y
|
||||
guard abs(dx) >= abs(dy),
|
||||
abs(dx) >= minSwipeDistance,
|
||||
abs(velocity.x) >= minSwipeVelocity else {
|
||||
return
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
resetSwipeTracking()
|
||||
case .changed:
|
||||
evaluateSwipe(dx: dx, dy: dy, velocity: velocity)
|
||||
case .ended, .cancelled, .failed:
|
||||
if !didDispatchSwipe {
|
||||
evaluateSwipe(dx: dx, dy: dy, velocity: velocity)
|
||||
}
|
||||
resetSwipeTracking()
|
||||
default:
|
||||
break
|
||||
}
|
||||
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
||||
}
|
||||
|
||||
private func evaluateSwipe(dx: CGFloat, dy: CGFloat, velocity: CGPoint) {
|
||||
guard !didDispatchSwipe else { return }
|
||||
|
||||
if swipeAxis == nil {
|
||||
if abs(dx) > abs(dy) * 1.2 {
|
||||
swipeAxis = .horizontal
|
||||
} else if abs(dy) > abs(dx) * 1.2 {
|
||||
swipeAxis = .vertical
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch swipeAxis {
|
||||
case .vertical:
|
||||
guard abs(dy) >= minSwipeDistance,
|
||||
abs(velocity.y) >= minSwipeVelocity else {
|
||||
return
|
||||
}
|
||||
didDispatchSwipe = true
|
||||
onVerticalSwipe?(dy < 0)
|
||||
case .horizontal:
|
||||
guard abs(dx) >= abs(dy),
|
||||
abs(dx) >= minSwipeDistance,
|
||||
abs(velocity.x) >= minSwipeVelocity else {
|
||||
return
|
||||
}
|
||||
didDispatchSwipe = true
|
||||
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func resetSwipeTracking() {
|
||||
didDispatchSwipe = false
|
||||
swipeAxis = nil
|
||||
}
|
||||
|
||||
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
<string>需要麦克风权限才能进行录音评测</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal file
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum AudioRecorderSessionError: LocalizedError {
|
||||
case inputUnavailable
|
||||
case startFailed
|
||||
case conversionFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .inputUnavailable:
|
||||
return "未检测到麦克风输入"
|
||||
case .startFailed:
|
||||
return "无法开始录制:请确认麦克风未被其他应用占用后重试"
|
||||
case .conversionFailed:
|
||||
return "录音文件转码失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于 AVAudioEngine 的录音会话。
|
||||
///
|
||||
/// AVAudioRecorder 在 iOS 26 真机上存在 record() 恒返回 false 的已知问题
|
||||
/// (Apple 开发者论坛多个报告),因此这里改用 AVAudioEngine 采集 PCM,
|
||||
/// 停止录音时再转码为 16kHz 单声道 AAC(m4a),与服务端评测接口保持一致。
|
||||
final class AudioRecorderSession {
|
||||
private let engine = AVAudioEngine()
|
||||
private let outputURL: URL
|
||||
private let pcmURL: URL
|
||||
private var inputFile: AVAudioFile?
|
||||
private var tapInstalled = false
|
||||
|
||||
private(set) var isRunning = false
|
||||
private(set) var recordDuration: TimeInterval = 0
|
||||
|
||||
/// 本次录音的原始 PCM 文件(caf),停止录音后仍保留,
|
||||
/// 供配音合成直接合并,避免在 iOS 26 上读回 AAC(AVAudioFile 存在 'fmt?' 问题)。
|
||||
var rawRecordingURL: URL { pcmURL }
|
||||
|
||||
init(outputURL: URL) {
|
||||
self.outputURL = outputURL
|
||||
let stamp = Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.pcmURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("raw-\(stamp).caf")
|
||||
}
|
||||
|
||||
/// 激活音频会话并开始采集。失败时抛出错误,可在重新激活会话后重试。
|
||||
func start() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
guard session.isInputAvailable else {
|
||||
throw AudioRecorderSessionError.inputUnavailable
|
||||
}
|
||||
|
||||
let input = engine.inputNode
|
||||
if !tapInstalled {
|
||||
let inputFormat = input.outputFormat(forBus: 0)
|
||||
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
||||
throw AudioRecorderSessionError.inputUnavailable
|
||||
}
|
||||
let file = try AVAudioFile(forWriting: pcmURL, settings: inputFormat.settings)
|
||||
inputFile = file
|
||||
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
try self.inputFile?.write(from: buffer)
|
||||
} catch {
|
||||
// 单个缓冲写入失败时忽略,继续录音
|
||||
}
|
||||
}
|
||||
tapInstalled = true
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
throw AudioRecorderSessionError.startFailed
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
/// 停止录音,将 PCM 转码为 m4a 写入 outputURL。
|
||||
func stop() throws {
|
||||
guard isRunning else { return }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
tapInstalled = false
|
||||
inputFile = nil
|
||||
try finalizeToAAC()
|
||||
}
|
||||
|
||||
/// 放弃本次录音并清理临时文件。
|
||||
func cancel() {
|
||||
if isRunning {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
tapInstalled = false
|
||||
inputFile = nil
|
||||
}
|
||||
try? FileManager.default.removeItem(at: pcmURL)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
private func finalizeToAAC() throws {
|
||||
let inputFile = try AVAudioFile(forReading: pcmURL)
|
||||
recordDuration = Double(inputFile.length) / inputFile.processingFormat.sampleRate
|
||||
let outputSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
]
|
||||
let outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings)
|
||||
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFile.processingFormat) else {
|
||||
throw AudioRecorderSessionError.conversionFailed
|
||||
}
|
||||
let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
|
||||
if inputFile.framePosition >= inputFile.length {
|
||||
outStatus.pointee = .endOfStream
|
||||
return nil
|
||||
}
|
||||
let remaining = inputFile.length - inputFile.framePosition
|
||||
let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining)))
|
||||
guard frames > 0,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
try inputFile.read(into: buffer)
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
} catch {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let capacity = AVAudioFrameCount(min(4096, max(1, Int(inputFile.length))))
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFile.processingFormat, frameCapacity: capacity) else {
|
||||
throw AudioRecorderSessionError.conversionFailed
|
||||
}
|
||||
while true {
|
||||
var conversionError: NSError?
|
||||
let status = converter.convert(to: outputBuffer, error: &conversionError, withInputFrom: inputBlock)
|
||||
switch status {
|
||||
case .haveData:
|
||||
try outputFile.write(from: outputBuffer)
|
||||
case .inputRanDry, .endOfStream:
|
||||
return
|
||||
case .error:
|
||||
throw conversionError ?? AudioRecorderSessionError.conversionFailed
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct AuthUser: Codable {
|
||||
let id: String
|
||||
let username: String
|
||||
let nickname: String
|
||||
}
|
||||
|
||||
struct AuthSession: Codable {
|
||||
let token: String
|
||||
let user: AuthUser
|
||||
}
|
||||
|
||||
struct SsoExchangeResult: Codable {
|
||||
let token: String
|
||||
let user: AuthUser
|
||||
let phoneBound: Bool
|
||||
let isNewUser: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case token, user
|
||||
case phoneBound = "phone_bound"
|
||||
case isNewUser = "is_new_user"
|
||||
}
|
||||
}
|
||||
|
||||
struct Course: Identifiable, Codable {
|
||||
let videoHash: String
|
||||
let title: String
|
||||
let durationMs: Int64?
|
||||
let language: String?
|
||||
let sentenceCount: Int
|
||||
let streamUrl: String
|
||||
let enrolled: Bool
|
||||
|
||||
var id: String { videoHash }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case title, language, enrolled
|
||||
case videoHash = "video_hash"
|
||||
case durationMs = "duration_ms"
|
||||
case sentenceCount = "sentence_count"
|
||||
case streamUrl = "stream_url"
|
||||
}
|
||||
|
||||
func toVideoSummary() -> TrainingVideoSummary {
|
||||
TrainingVideoSummary(
|
||||
videoHash: videoHash,
|
||||
title: title,
|
||||
streamUrl: streamUrl,
|
||||
durationMs: durationMs,
|
||||
sizeBytes: 0,
|
||||
language: language,
|
||||
sentenceCount: sentenceCount,
|
||||
status: "ready"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth Error
|
||||
|
||||
enum AuthError: LocalizedError {
|
||||
case badStatus(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code, let body):
|
||||
return "HTTP \(code): \(body.prefix(200))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth API
|
||||
|
||||
enum AuthAPI {
|
||||
private static var baseURL: String { AppConfig.serverBaseURL }
|
||||
|
||||
// MARK: Login / Register
|
||||
|
||||
static func login(username: String, password: String) async throws -> AuthSession {
|
||||
try await authRequest(path: "/api/v1/auth/login", body: [
|
||||
"username": username,
|
||||
"password": password,
|
||||
])
|
||||
}
|
||||
|
||||
static func register(
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String,
|
||||
phone: String
|
||||
) async throws -> AuthSession {
|
||||
try await authRequest(path: "/api/v1/auth/register", body: [
|
||||
"username": username,
|
||||
"password": password,
|
||||
"nickname": nickname,
|
||||
"phone": phone,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: SSO
|
||||
|
||||
static func ssoExchange(jwtToken: String) async throws -> SsoExchangeResult {
|
||||
let url = URL(string: "\(baseURL)/api/v1/auth/sso/exchange")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["access_token": jwtToken])
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
return try JSONDecoder().decode(SsoExchangeResult.self, from: data)
|
||||
}
|
||||
|
||||
static func ssoBindPhone(token: String, phone: String) async throws {
|
||||
let url = URL(string: "\(baseURL)/api/v1/auth/sso/bind-phone")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["phone": phone])
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Courses
|
||||
|
||||
static func courses(token: String) async throws -> [Course] {
|
||||
let url = URL(string: "\(baseURL)/api/v1/courses")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
return try JSONDecoder().decode([Course].self, from: data)
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private static func authRequest(path: String, body: [String: Any]) async throws -> AuthSession {
|
||||
let url = URL(string: "\(baseURL)\(path)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
return try JSONDecoder().decode(AuthSession.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -2,61 +2,153 @@ import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum DubbingCompositorError: LocalizedError {
|
||||
case noAudioTracks
|
||||
case cannotReadDuration
|
||||
case inputReadFailed(String?)
|
||||
case outputWriteFailed(String?)
|
||||
case convertFailed(String?)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAudioTracks:
|
||||
return "录音没有音频轨道"
|
||||
case .cannotReadDuration:
|
||||
return "无法读取录音时长"
|
||||
case .inputReadFailed(let detail):
|
||||
return detail.map { "无法读取录音音频(\($0))" } ?? "无法读取录音音频"
|
||||
case .outputWriteFailed(let detail):
|
||||
return detail.map { "无法写入合成音频(\($0))" } ?? "无法写入合成音频"
|
||||
case .convertFailed(let detail):
|
||||
return detail.map { "无法转换音频格式(\($0))" } ?? "无法转换音频格式"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DubbingCompositor {
|
||||
/// 合并多段录音为一段 16kHz 单声道 AAC(m4a)。
|
||||
///
|
||||
/// 不使用 AVMutableComposition + AVAssetExportSession:iOS 26 / macOS 26 上
|
||||
/// AVAssetExportSession 对纯音频资源导出会失败(AVError -11800,底层 'fmt?'),
|
||||
/// AVAudioFile 读回 AAC 也会失败(CheckClientFormatSet / 'fmt?')。
|
||||
/// 因此传入录音时保留的原始 PCM(caf),解码拼接后直接编码 AAC,
|
||||
/// 与录音转码走同一条已验证可用的链路。
|
||||
static func merge(urls: [URL], outputURL: URL) async throws {
|
||||
try await mergeSync(urls: urls, outputURL: outputURL)
|
||||
do {
|
||||
try mergeSync(urls: urls, outputURL: outputURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergeSync(urls: [URL], outputURL: URL) async throws {
|
||||
private static func mergeSync(urls: [URL], outputURL: URL) throws {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
let composition = AVMutableComposition()
|
||||
var currentTime = CMTime.zero
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
]
|
||||
let outputFile: AVAudioFile
|
||||
do {
|
||||
outputFile = try AVAudioFile(forWriting: outputURL, settings: settings)
|
||||
} catch {
|
||||
throw DubbingCompositorError.outputWriteFailed(error.localizedDescription)
|
||||
}
|
||||
let targetFormat = outputFile.processingFormat
|
||||
for url in urls {
|
||||
let source = AVURLAsset(url: url)
|
||||
let tracks = try await source.loadTracks(withMediaType: .audio)
|
||||
guard let track = tracks.first else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
let inputFile: AVAudioFile
|
||||
do {
|
||||
inputFile = try AVAudioFile(forReading: url)
|
||||
} catch {
|
||||
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
|
||||
}
|
||||
let duration = try await source.load(.duration)
|
||||
guard duration.seconds.isFinite, duration.seconds > 0 else {
|
||||
throw DubbingCompositorError.cannotReadDuration
|
||||
guard inputFile.length > 0 else {
|
||||
throw DubbingCompositorError.inputReadFailed("录音为空")
|
||||
}
|
||||
let timeRange = CMTimeRange(start: .zero, duration: duration)
|
||||
let compositionTrack = composition.addMutableTrack(
|
||||
withMediaType: .audio,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
)
|
||||
guard let compositionTrack else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
let inputFormat = inputFile.processingFormat
|
||||
if inputFormat.sampleRate == targetFormat.sampleRate,
|
||||
inputFormat.channelCount == targetFormat.channelCount {
|
||||
try appendStream(from: inputFile, to: outputFile)
|
||||
} else {
|
||||
try appendConverted(from: inputFile, to: outputFile, targetFormat: targetFormat)
|
||||
}
|
||||
try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime)
|
||||
currentTime = currentTime + duration
|
||||
}
|
||||
}
|
||||
|
||||
guard let exporter = AVAssetExportSession(
|
||||
asset: composition,
|
||||
presetName: AVAssetExportPresetAppleM4A
|
||||
) else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
private static func appendStream(from inputFile: AVAudioFile, to outputFile: AVAudioFile) throws {
|
||||
var remaining = inputFile.length
|
||||
while remaining > 0 {
|
||||
let frames = AVAudioFrameCount(min(remaining, 8192))
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
|
||||
throw DubbingCompositorError.convertFailed("无法分配解码缓冲")
|
||||
}
|
||||
do {
|
||||
try inputFile.read(into: buffer)
|
||||
} catch {
|
||||
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
|
||||
}
|
||||
guard buffer.frameLength > 0 else {
|
||||
throw DubbingCompositorError.inputReadFailed("录音文件读取出错(读取到空帧)")
|
||||
}
|
||||
do {
|
||||
try outputFile.write(from: buffer)
|
||||
} catch {
|
||||
throw DubbingCompositorError.outputWriteFailed(error.localizedDescription)
|
||||
}
|
||||
remaining -= AVAudioFramePosition(buffer.frameLength)
|
||||
}
|
||||
exporter.outputURL = outputURL
|
||||
exporter.outputFileType = .m4a
|
||||
await exporter.export()
|
||||
guard exporter.status == .completed else {
|
||||
throw exporter.error ?? DubbingCompositorError.noAudioTracks
|
||||
}
|
||||
|
||||
private static func appendConverted(
|
||||
from inputFile: AVAudioFile,
|
||||
to outputFile: AVAudioFile,
|
||||
targetFormat: AVAudioFormat
|
||||
) throws {
|
||||
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: targetFormat) else {
|
||||
throw DubbingCompositorError.convertFailed("无法创建格式转换器")
|
||||
}
|
||||
let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
|
||||
if inputFile.framePosition >= inputFile.length {
|
||||
outStatus.pointee = .endOfStream
|
||||
return nil
|
||||
}
|
||||
let remaining = inputFile.length - inputFile.framePosition
|
||||
let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining)))
|
||||
guard frames > 0,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
try inputFile.read(into: buffer)
|
||||
guard buffer.frameLength > 0 else {
|
||||
outStatus.pointee = .endOfStream
|
||||
return nil
|
||||
}
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
} catch {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
}
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: 8192) else {
|
||||
throw DubbingCompositorError.convertFailed("无法分配转码缓冲")
|
||||
}
|
||||
while true {
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
|
||||
switch status {
|
||||
case .haveData:
|
||||
do {
|
||||
try outputFile.write(from: outputBuffer)
|
||||
} catch {
|
||||
throw DubbingCompositorError.outputWriteFailed(error.localizedDescription)
|
||||
}
|
||||
case .inputRanDry, .endOfStream:
|
||||
return
|
||||
case .error:
|
||||
if let error {
|
||||
throw DubbingCompositorError.convertFailed(error.localizedDescription)
|
||||
}
|
||||
throw DubbingCompositorError.convertFailed(nil)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,17 @@ enum HTTPClient {
|
||||
_ type: T.Type,
|
||||
baseURL: String,
|
||||
path: String,
|
||||
timeout: TimeInterval
|
||||
timeout: TimeInterval,
|
||||
additionalHeaders: [String: String] = [:]
|
||||
) async throws -> (value: T, effectiveBaseURL: String) {
|
||||
var headers = ["Accept": "application/json"]
|
||||
headers.merge(additionalHeaders) { _, new in new }
|
||||
let result = try await execute(
|
||||
baseURL: baseURL,
|
||||
path: path,
|
||||
method: "GET",
|
||||
timeout: timeout,
|
||||
headers: ["Accept": "application/json"]
|
||||
headers: headers
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
||||
|
||||
@@ -88,16 +88,20 @@ enum ImitationAssessor {
|
||||
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
|
||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
||||
|
||||
var headers = [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||
"X-Client-Key": AppConfig.assessmentAPIKey,
|
||||
]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: path,
|
||||
method: "POST",
|
||||
timeout: AppConfig.assessmentTimeout,
|
||||
headers: [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||
"X-Client-Key": AppConfig.assessmentAPIKey,
|
||||
],
|
||||
headers: headers,
|
||||
body: body
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
@@ -171,15 +175,19 @@ enum ImitationAssessor {
|
||||
}
|
||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
||||
|
||||
var headers = [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||
]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/dub-shares",
|
||||
method: "POST",
|
||||
timeout: 120,
|
||||
headers: [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||
],
|
||||
headers: headers,
|
||||
body: body
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
|
||||
@@ -20,11 +20,16 @@ enum SentenceBoundaryAPI {
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw SentenceBoundaryAPIError.invalidHash
|
||||
}
|
||||
var headers: [String: String] = [:]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.getJSON(
|
||||
SentenceBoundaryDocument.self,
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
||||
timeout: AppConfig.readTimeout
|
||||
timeout: AppConfig.readTimeout,
|
||||
additionalHeaders: headers
|
||||
)
|
||||
guard result.value.videoHash.lowercased() == hash else {
|
||||
throw SentenceBoundaryAPIError.hashMismatch
|
||||
@@ -32,3 +37,186 @@ enum SentenceBoundaryAPI {
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
struct SentenceExplanationResponse: Decodable {
|
||||
let explanation: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case explanation
|
||||
}
|
||||
}
|
||||
|
||||
struct SubtitleTranslationItem: Decodable {
|
||||
let index: Int
|
||||
let zhText: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case index
|
||||
case zhText = "zh_text"
|
||||
}
|
||||
}
|
||||
|
||||
struct SubtitleTranslationResponse: Decodable {
|
||||
let videoHash: String
|
||||
let translations: [SubtitleTranslationItem]
|
||||
let complete: Bool?
|
||||
let status: String?
|
||||
let error: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case translations
|
||||
case videoHash = "video_hash"
|
||||
case complete
|
||||
case status
|
||||
case error
|
||||
}
|
||||
|
||||
var mapped: [Int: String] {
|
||||
var values: [Int: String] = [:]
|
||||
for item in translations where !item.zhText.isEmpty {
|
||||
values[item.index] = item.zhText
|
||||
}
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
extension SentenceBoundaryAPI {
|
||||
static func explain(
|
||||
_ sentence: SentenceBoundary,
|
||||
courseTitle: String
|
||||
) async throws -> String {
|
||||
var body: [String: Any] = [
|
||||
"sentence_text": sentence.text ?? "",
|
||||
"course_title": courseTitle,
|
||||
"sentence_index": sentence.index,
|
||||
]
|
||||
if let language = sentence.language {
|
||||
body["language"] = language
|
||||
}
|
||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/sentences/explain",
|
||||
method: "POST",
|
||||
timeout: AppConfig.readTimeout,
|
||||
headers: headers,
|
||||
body: try JSONSerialization.data(withJSONObject: body)
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let detail = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, detail)
|
||||
}
|
||||
return try JSONDecoder()
|
||||
.decode(SentenceExplanationResponse.self, from: result.body)
|
||||
.explanation
|
||||
}
|
||||
|
||||
static func fetchStoredSubtitles(videoHash: String) async throws -> SubtitleTranslationResponse {
|
||||
try await requestSubtitles(videoHash: videoHash, method: "GET", timeout: AppConfig.readTimeout)
|
||||
}
|
||||
|
||||
static func translateSubtitles(videoHash: String) async throws -> SubtitleTranslationResponse {
|
||||
try await requestSubtitles(videoHash: videoHash, method: "POST", timeout: AppConfig.translationTimeout)
|
||||
}
|
||||
|
||||
static func loadSubtitles(
|
||||
videoHash: String,
|
||||
expectedIndexes: [Int],
|
||||
onProgress: (([Int: String]) -> Void)? = nil
|
||||
) async throws -> [Int: String] {
|
||||
let expected = Set(expectedIndexes)
|
||||
let deadline = Date().addingTimeInterval(AppConfig.translationDeadline)
|
||||
var kickJob = true
|
||||
var lastError: Error?
|
||||
var lastCount = -1
|
||||
var stalledRounds = 0
|
||||
while Date() < deadline {
|
||||
try Task.checkCancellation()
|
||||
if let stored = try? await fetchStoredSubtitles(videoHash: videoHash) {
|
||||
let mapped = stored.mapped
|
||||
onProgress?(mapped)
|
||||
if isComplete(mapped, expected: expected, payload: stored) {
|
||||
return mapped
|
||||
}
|
||||
if mapped.count > lastCount {
|
||||
lastCount = mapped.count
|
||||
stalledRounds = 0
|
||||
} else {
|
||||
stalledRounds += 1
|
||||
}
|
||||
if stored.status != "running" && stalledRounds >= 2 {
|
||||
kickJob = true
|
||||
}
|
||||
} else {
|
||||
kickJob = true
|
||||
}
|
||||
if kickJob {
|
||||
do {
|
||||
let posted = try await translateSubtitles(videoHash: videoHash)
|
||||
let mapped = posted.mapped
|
||||
onProgress?(mapped)
|
||||
if isComplete(mapped, expected: expected, payload: posted) {
|
||||
return mapped
|
||||
}
|
||||
if posted.status == "error" {
|
||||
lastError = HTTPClientError.badStatus(503, posted.error ?? "Subtitle translation failed")
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
if !isTimeout(error) {
|
||||
stalledRounds += 1
|
||||
}
|
||||
}
|
||||
kickJob = false
|
||||
}
|
||||
try await Task.sleep(nanoseconds: AppConfig.translationPollIntervalNs)
|
||||
}
|
||||
throw lastError ?? HTTPClientError.badStatus(408, "Subtitle translation timed out")
|
||||
}
|
||||
|
||||
private static func isComplete(
|
||||
_ mapped: [Int: String],
|
||||
expected: Set<Int>,
|
||||
payload: SubtitleTranslationResponse
|
||||
) -> Bool {
|
||||
if !expected.isEmpty {
|
||||
return expected.isSubset(of: Set(mapped.keys))
|
||||
}
|
||||
return payload.complete == true
|
||||
}
|
||||
|
||||
private static func isTimeout(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
||||
}
|
||||
|
||||
private static func requestSubtitles(
|
||||
videoHash: String,
|
||||
method: String,
|
||||
timeout: TimeInterval
|
||||
) async throws -> SubtitleTranslationResponse {
|
||||
let hash = videoHash.lowercased()
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw SentenceBoundaryAPIError.invalidHash
|
||||
}
|
||||
var headers: [String: String] = ["Accept": "application/json"]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos/\(hash)/subtitle-translation",
|
||||
method: method,
|
||||
timeout: timeout,
|
||||
headers: headers
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let detail = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, detail)
|
||||
}
|
||||
return try JSONDecoder().decode(SubtitleTranslationResponse.self, from: result.body)
|
||||
}
|
||||
}
|
||||
|
||||
67
ios/OralTrainer/Services/SentenceExplanationStore.swift
Normal file
67
ios/OralTrainer/Services/SentenceExplanationStore.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
final class SentenceExplanationStore {
|
||||
static let shared = SentenceExplanationStore()
|
||||
|
||||
private let root: URL
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
root = documents.appendingPathComponent("SentenceExplanations", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func load(mediaId: String, sentenceIndex: Int) -> String? {
|
||||
let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(mediaId: String, sentenceIndex: Int, explanation: String) -> Bool {
|
||||
let trimmed = explanation.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex)
|
||||
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
do {
|
||||
try trimmed.write(to: url, atomically: true, encoding: .utf8)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sync(mediaId: String, sentences: [SentenceBoundary]) {
|
||||
guard !sentences.isEmpty else { return }
|
||||
let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true)
|
||||
let revision = Self.revision(for: sentences)
|
||||
let marker = directory.appendingPathComponent("revision.txt")
|
||||
let previous = (try? String(contentsOf: marker, encoding: .utf8))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard previous != revision else { return }
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try? revision.write(to: marker, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func fileURL(mediaId: String, sentenceIndex: Int) -> URL {
|
||||
let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true)
|
||||
return directory.appendingPathComponent("\(sentenceIndex).txt")
|
||||
}
|
||||
|
||||
static func revision(for sentences: [SentenceBoundary]) -> String {
|
||||
sentences.map { sentence in
|
||||
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return "\(sentence.index)\t\(text)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func sanitize(_ mediaId: String) -> String {
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
||||
let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
let value = String(filtered).trimmingCharacters(in: CharacterSet(charactersIn: "_"))
|
||||
return value.isEmpty || value == "." || value == ".." ? "unknown" : value
|
||||
}
|
||||
}
|
||||
212
ios/OralTrainer/Services/StreamingVideoCache.swift
Normal file
212
ios/OralTrainer/Services/StreamingVideoCache.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// iOS 端的可选整文件视频缓存。
|
||||
///
|
||||
/// 播放默认走远程 URL 按需加载(AVPlayer HTTP Range)。若本地已有完整缓存文件,
|
||||
/// 播放器会优先使用该文件,避免重复拉流。
|
||||
@MainActor
|
||||
final class StreamingVideoCache {
|
||||
static let shared = StreamingVideoCache()
|
||||
|
||||
static let maxCacheBytes: Int64 = 512 * 1024 * 1024
|
||||
|
||||
private static let supportedExtensions: Set<String> = ["mp4", "m4v", "mov"]
|
||||
private static let fileExtension = "mp4"
|
||||
|
||||
private let directory: URL
|
||||
private let fileManager = FileManager.default
|
||||
private var downloadsByURL: [URL: Task<URL, Error>] = [:]
|
||||
|
||||
private init() {
|
||||
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
directory = caches.appendingPathComponent("oral-trainer-streaming-videos", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func cachedFileIfAvailable(for remoteURL: URL) -> URL? {
|
||||
guard isCacheable(remoteURL), let file = destinationFile(for: remoteURL) else {
|
||||
return nil
|
||||
}
|
||||
guard fileManager.fileExists(atPath: file.path), isUsableMediaFile(file) else {
|
||||
fileManager.removeItemIfPossible(at: file)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AVPlayer 读取后刷新 LRU 时间,与 Android LeastRecentlyUsedCacheEvictor 的
|
||||
// 语义保持接近。
|
||||
try? fileManager.setAttributes(
|
||||
[.modificationDate: Date()],
|
||||
ofItemAtPath: file.path
|
||||
)
|
||||
return file
|
||||
}
|
||||
|
||||
/// 重复调用同一 URL 时复用同一个后台下载;只有下载完成后才返回本地文件。
|
||||
func prefetch(_ remoteURL: URL) async throws -> URL {
|
||||
if let cached = cachedFileIfAvailable(for: remoteURL) {
|
||||
return cached
|
||||
}
|
||||
guard let destination = destinationFile(for: remoteURL) else {
|
||||
throw StreamingVideoCacheError.unsupportedURL
|
||||
}
|
||||
guard isCacheable(remoteURL) else {
|
||||
throw StreamingVideoCacheError.unsupportedURL
|
||||
}
|
||||
|
||||
if let existing = downloadsByURL[remoteURL] {
|
||||
return try await existing.value
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
try await Self.download(from: remoteURL, to: destination)
|
||||
}
|
||||
downloadsByURL[remoteURL] = task
|
||||
|
||||
let result: URL
|
||||
do {
|
||||
result = try await task.value
|
||||
} catch {
|
||||
if !task.isCancelled {
|
||||
downloadsByURL[remoteURL] = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if downloadsByURL[remoteURL]?.isCancelled != true {
|
||||
downloadsByURL[remoteURL] = nil
|
||||
}
|
||||
evictIfNeeded(keeping: remoteURL)
|
||||
return result
|
||||
}
|
||||
|
||||
func clear() {
|
||||
for task in downloadsByURL.values {
|
||||
task.cancel()
|
||||
}
|
||||
downloadsByURL.removeAll()
|
||||
try? fileManager.removeItem(at: directory)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func isCacheable(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
let ext = url.pathExtension.lowercased()
|
||||
// HLS/DASH 播放列表不能通过下载单个清单完成离线缓存;当前云端课程均为 MP4。
|
||||
return Self.supportedExtensions.contains(ext)
|
||||
}
|
||||
|
||||
private func destinationFile(for remoteURL: URL) -> URL? {
|
||||
let rawData = Data(remoteURL.absoluteString.utf8)
|
||||
let digest = SHA256.hash(data: rawData)
|
||||
let key = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return directory.appendingPathComponent(key).appendingPathExtension(Self.fileExtension)
|
||||
}
|
||||
|
||||
private func isUsableMediaFile(_ url: URL) -> Bool {
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
|
||||
guard values?.isRegularFile == true, let fileSize = values?.fileSize else {
|
||||
return false
|
||||
}
|
||||
let size = Int64(fileSize)
|
||||
return size > 0
|
||||
}
|
||||
|
||||
private func evictIfNeeded(keeping remoteURL: URL) {
|
||||
guard let keeping = destinationFile(for: remoteURL) else {
|
||||
return
|
||||
}
|
||||
|
||||
let contents = (try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
|
||||
var entries: [(url: URL, size: Int64, date: Date)] = contents.compactMap { url in
|
||||
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||
guard let fileSize = values?.fileSize, fileSize > 0 else {
|
||||
fileManager.removeItemIfPossible(at: url)
|
||||
return nil
|
||||
}
|
||||
let size = Int64(fileSize)
|
||||
return (url, size, values?.contentModificationDate ?? .distantPast)
|
||||
}
|
||||
|
||||
var totalBytes = entries.reduce(0) { $0 + $1.size }
|
||||
entries.sort { $0.date < $1.date }
|
||||
for entry in entries where totalBytes > Self.maxCacheBytes && entry.url != keeping {
|
||||
guard fileManager.removeItemIfPossible(at: entry.url) else {
|
||||
continue
|
||||
}
|
||||
totalBytes -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
private static func download(from remoteURL: URL, to destination: URL) async throws -> URL {
|
||||
var request = URLRequest(url: remoteURL)
|
||||
request.timeoutInterval = AppConfig.connectTimeout
|
||||
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (tempFile, response) = try await URLSession.shared.download(for: request)
|
||||
do {
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw StreamingVideoCacheError.downloadFailed(message: "HTTP \(status)")
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: destination)
|
||||
try fileManager.createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try fileManager.moveItem(at: tempFile, to: destination)
|
||||
return destination
|
||||
} catch {
|
||||
FileManager.default.removeItemIfPossible(at: tempFile)
|
||||
if let cacheError = error as? StreamingVideoCacheError {
|
||||
logFailure(remoteURL, message: cacheError.localizedDescription)
|
||||
throw cacheError
|
||||
}
|
||||
logFailure(remoteURL, message: error.localizedDescription)
|
||||
throw StreamingVideoCacheError.downloadFailed(
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func logFailure(_ url: URL, message: String) {
|
||||
#if DEBUG
|
||||
print("[StreamingVideoCache] 预缓存失败: \(url), \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamingVideoCacheError: LocalizedError {
|
||||
case unsupportedURL
|
||||
case downloadFailed(message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedURL:
|
||||
return "仅支持 MP4/M4V/MOV 整文件缓存"
|
||||
case .downloadFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FileManager {
|
||||
@discardableResult
|
||||
func removeItemIfPossible(at url: URL) -> Bool {
|
||||
do {
|
||||
try removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
struct PersistedRecording {
|
||||
let audioURL: URL
|
||||
let rawURL: URL
|
||||
}
|
||||
|
||||
struct TrainingHistorySnapshot {
|
||||
var audioSegments: [Int: URL] = [:]
|
||||
var rawSegments: [Int: URL] = [:]
|
||||
var scores: [Int: ImitationAssessmentResult] = [:]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TrainingHistoryManager: ObservableObject {
|
||||
@Published private(set) var historyVersion = 0
|
||||
let store = TrainingHistoryStore()
|
||||
|
||||
func notifyHistoryChanged() {
|
||||
historyVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
final class TrainingHistoryStore {
|
||||
private let root: URL
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
root = documents.appendingPathComponent("TrainingHistory", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func normalizedVideoHash(_ videoHash: String) -> String {
|
||||
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) }
|
||||
return String(String.UnicodeScalarView(filtered))
|
||||
}
|
||||
|
||||
private var userDirectory: URL {
|
||||
let identifier = normalizedVideoHash(AppConfig.currentUserId)
|
||||
return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true)
|
||||
}
|
||||
|
||||
func load(videoHash: String) -> TrainingHistorySnapshot {
|
||||
let directory = courseDirectory(videoHash)
|
||||
var snapshot = TrainingHistorySnapshot()
|
||||
for index in 0..<500 {
|
||||
let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index))
|
||||
let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index))
|
||||
if FileManager.default.fileExists(atPath: audio.path) {
|
||||
snapshot.audioSegments[index] = audio
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: raw.path) {
|
||||
snapshot.rawSegments[index] = raw
|
||||
}
|
||||
}
|
||||
|
||||
let scoreFile = directory.appendingPathComponent("scores.json")
|
||||
if let data = try? Data(contentsOf: scoreFile),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
for (key, value) in object {
|
||||
guard let index = Int(key), index >= 0 else { continue }
|
||||
if let payload = value as? [String: Any],
|
||||
let payloadData = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) {
|
||||
snapshot.scores[index] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func saveSegment(
|
||||
videoHash: String,
|
||||
sentenceIndex: Int,
|
||||
audioURL: URL,
|
||||
rawURL: URL
|
||||
) throws -> PersistedRecording {
|
||||
let directory = courseDirectory(videoHash)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let savedAudio = directory.appendingPathComponent(
|
||||
String(format: "segment-%04d.m4a", sentenceIndex)
|
||||
)
|
||||
let savedRaw = directory.appendingPathComponent(
|
||||
String(format: "raw-%04d.caf", sentenceIndex)
|
||||
)
|
||||
try? FileManager.default.removeItem(at: savedAudio)
|
||||
try? FileManager.default.removeItem(at: savedRaw)
|
||||
try FileManager.default.moveItem(at: audioURL, to: savedAudio)
|
||||
try FileManager.default.moveItem(at: rawURL, to: savedRaw)
|
||||
return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw)
|
||||
}
|
||||
|
||||
func audioDestination(videoHash: String, sentenceIndex: Int) -> URL {
|
||||
courseDirectory(videoHash).appendingPathComponent(
|
||||
String(format: "segment-%04d.m4a", sentenceIndex)
|
||||
)
|
||||
}
|
||||
|
||||
func saveScores(
|
||||
videoHash: String,
|
||||
scores: [Int: ImitationAssessmentResult]
|
||||
) {
|
||||
let directory = courseDirectory(videoHash)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
var object: [String: Any] = [:]
|
||||
for (index, score) in scores {
|
||||
object[String(index)] = Self.payload(score)
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
||||
try data.write(
|
||||
to: directory.appendingPathComponent("scores.json"),
|
||||
options: [.atomic]
|
||||
)
|
||||
} catch {
|
||||
// History persistence is best-effort; the server remains the source of record.
|
||||
}
|
||||
}
|
||||
|
||||
func removeScore(videoHash: String, sentenceIndex: Int) {
|
||||
var snapshot = load(videoHash: videoHash)
|
||||
snapshot.scores[sentenceIndex] = nil
|
||||
saveScores(videoHash: videoHash, scores: snapshot.scores)
|
||||
}
|
||||
|
||||
func removeAllAudio(videoHash: String) {
|
||||
let snapshot = load(videoHash: videoHash)
|
||||
for url in snapshot.audioSegments.values {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
for url in snapshot.rawSegments.values {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func courseDirectory(_ videoHash: String) -> URL {
|
||||
userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true)
|
||||
}
|
||||
|
||||
private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] {
|
||||
var value: [String: Any] = ["overall_score": score.overallScore]
|
||||
if let item = score.passed { value["passed"] = item }
|
||||
if let item = score.passScore { value["pass_score"] = item }
|
||||
if let item = score.contentScore { value["content_score"] = item }
|
||||
if let item = score.completenessScore { value["completeness_score"] = item }
|
||||
if let item = score.fluencyScore { value["fluency_score"] = item }
|
||||
if let item = score.pronunciationScore { value["pronunciation_score"] = item }
|
||||
if let item = score.prosodyScore { value["prosody_score"] = item }
|
||||
if let item = score.durationScore { value["duration_score"] = item }
|
||||
if let item = score.pauseScore { value["pause_score"] = item }
|
||||
if let item = score.speechRateScore { value["speech_rate_score"] = item }
|
||||
if let item = score.referenceText { value["reference_text"] = item }
|
||||
if let item = score.recognizedText { value["recognized_text"] = item }
|
||||
if let item = score.durationRatio { value["duration_ratio"] = item }
|
||||
if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens }
|
||||
if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens }
|
||||
if !score.substitutions.isEmpty {
|
||||
value["substitutions"] = score.substitutions.map {
|
||||
["expected": $0.expected, "actual": $0.actual]
|
||||
}
|
||||
}
|
||||
if let item = score.feedback { value["feedback"] = item }
|
||||
if !score.details.isEmpty { value["details"] = score.details }
|
||||
return value
|
||||
}
|
||||
}
|
||||
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
|
||||
struct UserResultSummary: Identifiable, Decodable {
|
||||
let attemptId: String
|
||||
let videoHash: String
|
||||
let courseTitle: String
|
||||
let sentenceIndex: Int
|
||||
let sentenceText: String
|
||||
let overallScore: Float
|
||||
let passed: Bool
|
||||
let createdAt: String
|
||||
|
||||
var id: String { attemptId }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case overallScore = "overall_score"
|
||||
case courseTitle = "course_title"
|
||||
case sentenceIndex = "sentence_index"
|
||||
case sentenceText = "sentence_text"
|
||||
case createdAt = "created_at"
|
||||
case attemptId = "attempt_id"
|
||||
case videoHash = "video_hash"
|
||||
case passed
|
||||
}
|
||||
}
|
||||
|
||||
struct UserDubShareSummary: Identifiable, Decodable {
|
||||
let shareId: String
|
||||
let videoHash: String
|
||||
let courseTitle: String
|
||||
let title: String
|
||||
let segmentCount: Int
|
||||
let averageScore: Float
|
||||
let createdAt: String
|
||||
|
||||
var id: String { shareId }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case averageScore = "average_score"
|
||||
case courseTitle = "course_title"
|
||||
case segmentCount = "segment_count"
|
||||
case createdAt = "created_at"
|
||||
case shareId = "share_id"
|
||||
case videoHash = "video_hash"
|
||||
case title
|
||||
}
|
||||
}
|
||||
|
||||
struct DubShareSegmentDetail: Decodable {
|
||||
let index: Int
|
||||
let overallScore: Float
|
||||
let recognizedText: String
|
||||
let scoreDetails: [String: Float]
|
||||
let audioURL: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case overallScore = "overall_score"
|
||||
case recognizedText = "recognized_text"
|
||||
case scoreDetails = "score_details"
|
||||
case audioURL = "audio_url"
|
||||
case index
|
||||
}
|
||||
}
|
||||
|
||||
struct DubShareDetail: Decodable {
|
||||
let shareId: String
|
||||
let videoHash: String
|
||||
let title: String
|
||||
let segments: [DubShareSegmentDetail]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case shareId = "share_id"
|
||||
case videoHash = "video_hash"
|
||||
case title
|
||||
case segments
|
||||
}
|
||||
}
|
||||
|
||||
enum UserHistoryError: LocalizedError {
|
||||
case badStatus(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code, let body):
|
||||
return "HTTP \(code): \(body.prefix(200))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UserHistoryAPI {
|
||||
static func results(token: String) async throws -> [UserResultSummary] {
|
||||
try await getJSON([UserResultSummary].self, path: "api/v1/me/results", token: token)
|
||||
}
|
||||
|
||||
static func dubShares(token: String) async throws -> [UserDubShareSummary] {
|
||||
try await getJSON([UserDubShareSummary].self, path: "api/v1/me/dub-shares", token: token)
|
||||
}
|
||||
|
||||
static func dubShareDetail(token: String, shareId: String) async throws -> DubShareDetail {
|
||||
try await getJSON(
|
||||
DubShareDetail.self,
|
||||
path: "api/v1/dub-shares/\(shareId)",
|
||||
token: token
|
||||
)
|
||||
}
|
||||
|
||||
static func deleteResult(token: String, attemptId: String) async throws {
|
||||
try await delete(path: "api/v1/me/results/\(attemptId)", token: token)
|
||||
}
|
||||
|
||||
static func deleteDubShare(token: String, shareId: String) async throws {
|
||||
try await delete(path: "api/v1/me/dub-shares/\(shareId)", token: token)
|
||||
}
|
||||
|
||||
static func downloadAudio(
|
||||
token: String,
|
||||
audioPath: String,
|
||||
destination: URL
|
||||
) async throws {
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: audioPath,
|
||||
method: "GET",
|
||||
timeout: AppConfig.readTimeout,
|
||||
headers: authorizationHeaders(token: token)
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
throw UserHistoryError.badStatus(
|
||||
result.statusCode,
|
||||
String(data: result.body, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
try result.body.write(to: destination, options: .atomic)
|
||||
}
|
||||
|
||||
private static func getJSON<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
path: String,
|
||||
token: String
|
||||
) async throws -> T {
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: path,
|
||||
method: "GET",
|
||||
timeout: AppConfig.readTimeout,
|
||||
headers: authorizationHeaders(token: token)
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
throw UserHistoryError.badStatus(
|
||||
result.statusCode,
|
||||
String(data: result.body, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
return try JSONDecoder().decode(T.self, from: result.body)
|
||||
}
|
||||
|
||||
private static func delete(path: String, token: String) async throws {
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: path,
|
||||
method: "DELETE",
|
||||
timeout: AppConfig.readTimeout,
|
||||
headers: authorizationHeaders(token: token)
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
throw UserHistoryError.badStatus(
|
||||
result.statusCode,
|
||||
String(data: result.body, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func authorizationHeaders(token: String) -> [String: String] {
|
||||
token.isEmpty ? [:] : ["Authorization": "Bearer \(token)"]
|
||||
}
|
||||
}
|
||||
533
ios/OralTrainer/Views/AuthView.swift
Normal file
533
ios/OralTrainer/Views/AuthView.swift
Normal file
@@ -0,0 +1,533 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Auth Manager (shared state)
|
||||
|
||||
@MainActor
|
||||
final class AuthManager: ObservableObject {
|
||||
@Published var token: String
|
||||
@Published var nickname: String
|
||||
@Published var userID: String
|
||||
@Published var showingRegister = false
|
||||
@Published var showingSSO = false
|
||||
@Published var showingPhoneBinding = false
|
||||
@Published var ssoPendingToken: String?
|
||||
@Published var ssoPendingUserId = ""
|
||||
|
||||
init() {
|
||||
let defaults = UserDefaults.standard
|
||||
token = defaults.string(forKey: "oral_trainer_token") ?? ""
|
||||
nickname = defaults.string(forKey: "oral_trainer_nickname") ?? ""
|
||||
let userID = defaults.string(forKey: "oral_trainer_user_id") ?? ""
|
||||
AppConfig.currentUserId = userID
|
||||
self.userID = userID
|
||||
AppConfig.authToken = token
|
||||
}
|
||||
|
||||
var isLoggedIn: Bool { !token.isEmpty }
|
||||
|
||||
func saveSession(_ session: AuthSession) {
|
||||
token = session.token
|
||||
nickname = session.user.nickname
|
||||
AppConfig.authToken = session.token
|
||||
userID = session.user.id
|
||||
AppConfig.currentUserId = session.user.id
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(session.token, forKey: "oral_trainer_token")
|
||||
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
|
||||
defaults.set(session.user.id, forKey: "oral_trainer_user_id")
|
||||
}
|
||||
|
||||
func logout() {
|
||||
token = ""
|
||||
nickname = ""
|
||||
AppConfig.authToken = ""
|
||||
userID = ""
|
||||
AppConfig.currentUserId = ""
|
||||
showingRegister = false
|
||||
showingSSO = false
|
||||
showingPhoneBinding = false
|
||||
ssoPendingToken = nil
|
||||
ssoPendingUserId = ""
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "oral_trainer_token")
|
||||
defaults.removeObject(forKey: "oral_trainer_nickname")
|
||||
defaults.removeObject(forKey: "oral_trainer_user_id")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth View (root auth screen)
|
||||
|
||||
struct AuthView: View {
|
||||
@ObservedObject var auth: AuthManager
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var statusText = ""
|
||||
@State private var isLoading = false
|
||||
var onLoginSuccess: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Spacer()
|
||||
Text("八哥口语")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Text("请先注册或登录后开始学习")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
TextField("用户名", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
SecureField("密码(至少 8 位)", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(12)
|
||||
.padding(.top, 8)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
Button {
|
||||
Task { await login() }
|
||||
} label: {
|
||||
Text(isLoading ? "登录中..." : "登录")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Theme.accent)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.disabled(isLoading)
|
||||
|
||||
Button {
|
||||
auth.showingRegister = true
|
||||
} label: {
|
||||
Text("注册")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Theme.surface)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Text("── 或 ──")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 12)
|
||||
|
||||
Button {
|
||||
auth.showingSSO = true
|
||||
} label: {
|
||||
Text("第一课堂一键登录")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Theme.buttonBlue)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
if !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentLight)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
.fullScreenCover(isPresented: $auth.showingRegister) {
|
||||
RegisterView(auth: auth) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $auth.showingSSO) {
|
||||
SSOWebView(auth: auth) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $auth.showingPhoneBinding) {
|
||||
PhoneBindingView(auth: auth) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func login() async {
|
||||
guard !username.isEmpty, !password.isEmpty else {
|
||||
statusText = "请填写用户名和密码"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
statusText = "登录中..."
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let session = try await AuthAPI.login(username: username, password: password)
|
||||
auth.saveSession(session)
|
||||
onLoginSuccess()
|
||||
} catch {
|
||||
statusText = "失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Register View
|
||||
|
||||
struct RegisterView: View {
|
||||
@ObservedObject var auth: AuthManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var nickname = ""
|
||||
@State private var phone = ""
|
||||
@State private var statusText = ""
|
||||
@State private var isLoading = false
|
||||
var onLoginSuccess: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("注册账号")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Text("填写以下信息完成注册")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
fieldLabel("用户名")
|
||||
TextField("3-50 位字母、数字或下划线", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
fieldLabel("密码")
|
||||
SecureField("至少 8 位", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
fieldLabel("姓名")
|
||||
TextField("你的真实姓名或昵称", text: $nickname)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
fieldLabel("手机号")
|
||||
TextField("请输入 11 位手机号", text: $phone)
|
||||
.textFieldStyle(.plain)
|
||||
.keyboardType(.phonePad)
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
Button {
|
||||
Task { await register() }
|
||||
} label: {
|
||||
Text(isLoading ? "注册中..." : "注册")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Theme.accent)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.disabled(isLoading)
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("返回登录")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Theme.surface)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
if !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentLight)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func fieldLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func register() async {
|
||||
guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return }
|
||||
guard password.count >= 8 else { statusText = "密码至少 8 位"; return }
|
||||
guard !nickname.isEmpty else { statusText = "请填写姓名"; return }
|
||||
let cleanedPhone = phone.trimmingCharacters(in: .whitespaces)
|
||||
guard cleanedPhone.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else {
|
||||
statusText = "请输入有效的 11 位手机号"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
statusText = "注册中..."
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let session = try await AuthAPI.register(
|
||||
username: username, password: password, nickname: nickname, phone: cleanedPhone
|
||||
)
|
||||
auth.saveSession(session)
|
||||
dismiss()
|
||||
onLoginSuccess()
|
||||
} catch {
|
||||
statusText = "注册失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SSO WebView
|
||||
|
||||
import WebKit
|
||||
|
||||
struct SSOWebView: View {
|
||||
@ObservedObject var auth: AuthManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var onLoginSuccess: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("取消登录")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Theme.surface)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
SSOWebViewRepresentable()
|
||||
|
||||
Button {
|
||||
readJwtAndLogin()
|
||||
} label: {
|
||||
Text("已登录,点击继续")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Theme.accent)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func readJwtAndLogin() {
|
||||
// Read JWT from WKWebView's localStorage via JavaScript
|
||||
SSOWebViewHolder.shared.readToken { jwt in
|
||||
guard let jwt = jwt, !jwt.isEmpty, jwt != "null" else {
|
||||
DispatchQueue.main.async {
|
||||
// Toast
|
||||
}
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await exchangeJwt(jwt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exchangeJwt(_ jwt: String) async {
|
||||
do {
|
||||
let result = try await AuthAPI.ssoExchange(jwtToken: jwt)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
if !result.phoneBound {
|
||||
auth.ssoPendingToken = result.token
|
||||
auth.ssoPendingUserId = result.user.id
|
||||
auth.showingPhoneBinding = true
|
||||
} else {
|
||||
auth.saveSession(AuthSession(token: result.token, user: result.user))
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SSO WebView Representable
|
||||
|
||||
struct SSOWebViewRepresentable: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .default()
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
SSOWebViewHolder.shared.webView = webView
|
||||
if let url = URL(string: "https://d1kt.cn/login") {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Holder for WebView reference
|
||||
|
||||
final class SSOWebViewHolder {
|
||||
static let shared = SSOWebViewHolder()
|
||||
weak var webView: WKWebView?
|
||||
|
||||
func readToken(completion: @escaping (String?) -> Void) {
|
||||
guard let webView = webView else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
webView.evaluateJavaScript(
|
||||
"(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
|
||||
) { result, _ in
|
||||
let token = (result as? String) ?? ""
|
||||
completion(token.isEmpty || token == "null" ? nil : token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone Binding View
|
||||
|
||||
struct PhoneBindingView: View {
|
||||
@ObservedObject var auth: AuthManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var phone = ""
|
||||
@State private var statusText = ""
|
||||
@State private var isLoading = false
|
||||
var onLoginSuccess: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("绑定手机号")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Text("已通过第一课堂登录,请绑定手机号后继续")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
fieldLabel("手机号")
|
||||
TextField("请输入 11 位手机号", text: $phone)
|
||||
.textFieldStyle(.plain)
|
||||
.keyboardType(.phonePad)
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||
|
||||
Button {
|
||||
Task { await bindPhone() }
|
||||
} label: {
|
||||
Text(isLoading ? "绑定中..." : "绑定并继续")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Theme.accent)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.disabled(isLoading)
|
||||
|
||||
if !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentLight)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 60)
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func fieldLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func bindPhone() async {
|
||||
let cleaned = phone.trimmingCharacters(in: .whitespaces)
|
||||
guard cleaned.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else {
|
||||
statusText = "请输入有效的 11 位手机号"
|
||||
return
|
||||
}
|
||||
guard let token = auth.ssoPendingToken else {
|
||||
statusText = "登录状态已过期,请重新登录"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
statusText = "绑定中..."
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
try await AuthAPI.ssoBindPhone(token: token, phone: cleaned)
|
||||
auth.showingPhoneBinding = false
|
||||
auth.ssoPendingToken = nil
|
||||
let userID = auth.ssoPendingUserId
|
||||
auth.saveSession(
|
||||
AuthSession(
|
||||
token: token,
|
||||
user: AuthUser(id: userID, username: "", nickname: "")
|
||||
)
|
||||
)
|
||||
dismiss()
|
||||
onLoginSuccess()
|
||||
} catch {
|
||||
statusText = "绑定失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ final class CatalogViewModel: ObservableObject {
|
||||
|
||||
private var baseURL = AppConfig.serverBaseURL
|
||||
private var hasLoaded = false
|
||||
private var boundaryDocuments: [String: SentenceBoundaryDocument] = [:]
|
||||
|
||||
func loadIfNeeded() async {
|
||||
guard !hasLoaded else {
|
||||
@@ -31,6 +32,42 @@ final class CatalogViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func preloadBoundaries(for videos: [TrainingVideoSummary]) async -> (
|
||||
videos: [TrainingVideoSummary],
|
||||
succeeded: Int,
|
||||
failed: Int
|
||||
) {
|
||||
var updatedVideos = videos
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
|
||||
for (index, video) in videos.enumerated() {
|
||||
do {
|
||||
let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash)
|
||||
boundaryDocuments[document.videoHash] = document
|
||||
SentenceExplanationStore.shared.sync(
|
||||
mediaId: document.videoHash,
|
||||
sentences: document.sentences
|
||||
)
|
||||
updatedVideos[index] = video.copy(
|
||||
durationMs: document.durationMs,
|
||||
sentenceCount: document.sentences.count
|
||||
)
|
||||
if activeVideoHash == video.videoHash {
|
||||
controller?.updateBoundaries(document.sentences)
|
||||
controller?.boundaryState = .loaded(count: document.sentences.count)
|
||||
controller?.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||
}
|
||||
succeeded += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
videos = updatedVideos
|
||||
return (updatedVideos, succeeded, failed)
|
||||
}
|
||||
|
||||
/// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。
|
||||
func select(_ video: TrainingVideoSummary) {
|
||||
guard let controller = controller else {
|
||||
@@ -41,6 +78,23 @@ final class CatalogViewModel: ObservableObject {
|
||||
controller.boundaryState = .failed(message: "无法解析视频流地址")
|
||||
return
|
||||
}
|
||||
if let document = boundaryDocuments[video.videoHash] {
|
||||
SentenceExplanationStore.shared.sync(
|
||||
mediaId: document.videoHash,
|
||||
sentences: document.sentences
|
||||
)
|
||||
let item = TrainingMediaItem(
|
||||
id: video.videoHash,
|
||||
title: video.title,
|
||||
url: url,
|
||||
sentences: document.sentences
|
||||
)
|
||||
controller.load(item)
|
||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||
return
|
||||
}
|
||||
|
||||
let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url)
|
||||
controller.load(loadingItem)
|
||||
controller.boundaryState = .loading(isLocal: false)
|
||||
@@ -51,13 +105,12 @@ final class CatalogViewModel: ObservableObject {
|
||||
guard activeVideoHash == video.videoHash else {
|
||||
return
|
||||
}
|
||||
let item = TrainingMediaItem(
|
||||
id: video.videoHash,
|
||||
title: video.title,
|
||||
url: url,
|
||||
boundaryDocuments[document.videoHash] = document
|
||||
SentenceExplanationStore.shared.sync(
|
||||
mediaId: document.videoHash,
|
||||
sentences: document.sentences
|
||||
)
|
||||
controller.load(item)
|
||||
controller.updateBoundaries(document.sentences)
|
||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||
} catch {
|
||||
@@ -101,14 +154,11 @@ final class CatalogViewModel: ObservableObject {
|
||||
guard activeVideoHash == hash else {
|
||||
return
|
||||
}
|
||||
let loadedItem = TrainingMediaItem(
|
||||
id: hash,
|
||||
title: fileName,
|
||||
url: tempURL,
|
||||
sentences: document.sentences,
|
||||
isLocalFile: true
|
||||
SentenceExplanationStore.shared.sync(
|
||||
mediaId: document.videoHash,
|
||||
sentences: document.sentences
|
||||
)
|
||||
controller.load(loadedItem)
|
||||
controller.updateBoundaries(document.sentences)
|
||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
|
||||
} catch {
|
||||
|
||||
@@ -3,35 +3,117 @@ import SwiftUI
|
||||
/// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。
|
||||
struct FullscreenPlayerView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
var showsLessonTitle: Bool = true
|
||||
var showChineseSubtitles: Bool = false
|
||||
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||
let onExit: () -> Void
|
||||
|
||||
@State private var subtitleVisible = true
|
||||
@State private var explanationSentence: SentenceBoundary?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let fakeLandscape = proxy.size.height > proxy.size.width
|
||||
playerChrome
|
||||
.frame(
|
||||
width: fakeLandscape ? proxy.size.height : proxy.size.width,
|
||||
height: fakeLandscape ? proxy.size.width : proxy.size.height
|
||||
)
|
||||
.rotationEffect(.degrees(fakeLandscape ? -90 : 0))
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
}
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
.statusBarHidden(true)
|
||||
.sheet(item: $explanationSentence) { sentence in
|
||||
SentenceExplanationSheet(
|
||||
mediaId: controller.currentItem?.id ?? "",
|
||||
courseTitle: controller.currentItem?.title ?? "口语训练课程",
|
||||
sentence: sentence,
|
||||
sentences: controller.currentItem?.sentences ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var playerChrome: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
PlayerSurfaceView(controller: controller)
|
||||
PlayerSurfaceView(controller: controller) { isSwipeUp in
|
||||
guard subtitleVisible != isSwipeUp else {
|
||||
return
|
||||
}
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
subtitleVisible = isSwipeUp
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
HStack(spacing: 8) {
|
||||
overlayPill(timeText)
|
||||
Spacer()
|
||||
overlayPill(Theme.formatSpeed(controller.snapshot.speed))
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.allowsHitTesting(false)
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(controller.currentItem?.title ?? "未选择课程")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
if subtitleVisible {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if showsLessonTitle {
|
||||
Text(controller.currentItem?.title ?? "未选择课程")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
if let chineseSubtitleText = chineseSubtitleText {
|
||||
Text(chineseSubtitleText)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
guard let sentence = controller.currentSentence,
|
||||
!(sentence.text ?? "").isEmpty else {
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
explanationSentence = sentence
|
||||
} label: {
|
||||
Image(systemName: "lightbulb.circle.fill")
|
||||
.font(.system(size: 21, weight: .semibold))
|
||||
.foregroundColor(
|
||||
controller.currentSentence?.text?.isEmpty == false
|
||||
? Theme.accentLight
|
||||
: Theme.textSubtle
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(Circle())
|
||||
.accessibilityLabel("AI 讲解这句话")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.overlayFaint)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.overlay)
|
||||
}
|
||||
VStack {
|
||||
Spacer()
|
||||
@@ -55,6 +137,11 @@ struct FullscreenPlayerView: View {
|
||||
@State private var progressDragging = false
|
||||
@State private var dragPositionMs: Int64 = 0
|
||||
|
||||
private var timeText: String {
|
||||
let snapshot = controller.snapshot
|
||||
return "\(Theme.formatTime(snapshot.positionMs)) / \(Theme.formatTime(snapshot.durationMs))"
|
||||
}
|
||||
|
||||
private var progressRow: some View {
|
||||
let duration = controller.snapshot.durationMs
|
||||
return HStack(spacing: 8) {
|
||||
@@ -115,10 +202,10 @@ struct FullscreenPlayerView: View {
|
||||
case .loading(let isLocal):
|
||||
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
|
||||
case .failed:
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
return ""
|
||||
case .idle, .loaded:
|
||||
guard let sentence = controller.currentSentence else {
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
return ""
|
||||
}
|
||||
if let text = sentence.text, !text.isEmpty {
|
||||
return text
|
||||
@@ -127,10 +214,37 @@ struct FullscreenPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var chineseSubtitleText: String? {
|
||||
guard showChineseSubtitles else {
|
||||
return nil
|
||||
}
|
||||
if let zh = controller.currentSentence?.zhText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!zh.isEmpty {
|
||||
return zh
|
||||
}
|
||||
if (controller.currentSentence?.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
return chineseSubtitlePlaceholder
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.cacheState.statusMessage {
|
||||
return message
|
||||
}
|
||||
if let message = controller.transientMessage {
|
||||
return message
|
||||
}
|
||||
return controller.playbackStatusText(controller.snapshot)
|
||||
}
|
||||
|
||||
private func overlayPill(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ struct PlayerSectionView: View {
|
||||
var showsContinuousToggle: Bool
|
||||
@Binding var continuousPlayback: Bool
|
||||
var showsSubtitle: Binding<Bool>? = .constant(true)
|
||||
var showsLessonTitle: Bool = true
|
||||
var showChineseSubtitles: Binding<Bool>? = nil
|
||||
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||
var onFullscreen: () -> Void
|
||||
|
||||
@State private var progressDragging = false
|
||||
@State private var dragPositionMs: Int64 = 0
|
||||
@State private var explanationSentence: SentenceBoundary?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -19,6 +23,14 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.sheet(item: $explanationSentence) { sentence in
|
||||
SentenceExplanationSheet(
|
||||
mediaId: controller.currentItem?.id ?? "",
|
||||
courseTitle: controller.currentItem?.title ?? "口语训练课程",
|
||||
sentence: sentence,
|
||||
sentences: controller.currentItem?.sentences ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var playerSurface: some View {
|
||||
@@ -66,10 +78,12 @@ struct PlayerSectionView: View {
|
||||
|
||||
private var sentencePanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(controller.currentItem?.title ?? "未选择课程")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
if showsLessonTitle {
|
||||
Text(controller.currentItem?.title ?? "未选择课程")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if showsContinuousToggle {
|
||||
HStack(spacing: 0) {
|
||||
Text("连续播放")
|
||||
@@ -82,13 +96,56 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
if let showChineseSubtitles = showChineseSubtitles {
|
||||
HStack(spacing: 0) {
|
||||
Text("中文字幕")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
Spacer()
|
||||
Toggle("", isOn: showChineseSubtitles)
|
||||
.labelsHidden()
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(4)
|
||||
if let chineseSubtitleText = chineseSubtitleText {
|
||||
Text(chineseSubtitleText)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else {
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
explanationSentence = sentence
|
||||
} label: {
|
||||
Image(systemName: "lightbulb.circle.fill")
|
||||
.font(.system(size: 23, weight: .semibold))
|
||||
.foregroundColor(
|
||||
controller.currentSentence?.text?.isEmpty == false
|
||||
? Theme.accentLight
|
||||
: Theme.textSubtle
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(Circle())
|
||||
.accessibilityLabel("AI 讲解这句话")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
@@ -159,10 +216,10 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
return controller.currentItem?.title ?? "加载中"
|
||||
case .failed:
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
return ""
|
||||
case .idle, .loaded:
|
||||
guard let sentence = controller.currentSentence else {
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
return ""
|
||||
}
|
||||
if let text = sentence.text, !text.isEmpty {
|
||||
return text
|
||||
@@ -171,7 +228,24 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var chineseSubtitleText: String? {
|
||||
guard showChineseSubtitles?.wrappedValue == true else {
|
||||
return nil
|
||||
}
|
||||
if let zh = controller.currentSentence?.zhText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!zh.isEmpty {
|
||||
return zh
|
||||
}
|
||||
if (controller.currentSentence?.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
return chineseSubtitlePlaceholder
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.cacheState.statusMessage {
|
||||
return message
|
||||
}
|
||||
if let message = controller.transientMessage {
|
||||
return message
|
||||
}
|
||||
@@ -195,3 +269,144 @@ struct PlayerSectionView: View {
|
||||
controller.setPlaybackSpeed(next)
|
||||
}
|
||||
}
|
||||
|
||||
struct SentenceExplanationSheet: View {
|
||||
let mediaId: String
|
||||
let courseTitle: String
|
||||
let sentence: SentenceBoundary
|
||||
var sentences: [SentenceBoundary] = []
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var explanation = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var saved = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(sentence.text ?? "")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.danger)
|
||||
} else if explanation.isEmpty {
|
||||
Text("AI 正在讲解这句话…")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
} else {
|
||||
MarkdownExplanationText(text: explanation)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.white.ignoresSafeArea())
|
||||
.navigationTitle("句子讲解")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 6) {
|
||||
Button(saved ? "已保存" : "保存") {
|
||||
saveExplanation()
|
||||
}
|
||||
.disabled(saved || explanation.isEmpty || errorMessage != nil)
|
||||
Text(saved ? "(已保存本地)" : "(下次直接看本地)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
}
|
||||
.accessibilityLabel("关闭句子讲解")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
SentenceExplanationStore.shared.sync(mediaId: mediaId, sentences: sentences)
|
||||
if let cached = SentenceExplanationStore.shared.load(
|
||||
mediaId: mediaId,
|
||||
sentenceIndex: sentence.index
|
||||
) {
|
||||
explanation = cached
|
||||
saved = true
|
||||
return
|
||||
}
|
||||
guard explanation.isEmpty, errorMessage == nil else { return }
|
||||
do {
|
||||
explanation = try await SentenceBoundaryAPI.explain(
|
||||
sentence,
|
||||
courseTitle: courseTitle
|
||||
)
|
||||
} catch {
|
||||
errorMessage = "讲解失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveExplanation() {
|
||||
guard !saved, !explanation.isEmpty, errorMessage == nil else { return }
|
||||
saved = SentenceExplanationStore.shared.save(
|
||||
mediaId: mediaId,
|
||||
sentenceIndex: sentence.index,
|
||||
explanation: explanation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MarkdownExplanationText: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(Self.attributed(from: text))
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Theme.textDark)
|
||||
.tint(Theme.buttonBlue)
|
||||
.lineSpacing(5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private static func attributed(from source: String) -> AttributedString {
|
||||
let options = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .full,
|
||||
failurePolicy: .returnPartiallyParsedIfPossible
|
||||
)
|
||||
if let parsed = try? AttributedString(
|
||||
markdown: preservingLineBreaks(source),
|
||||
options: options
|
||||
) {
|
||||
return parsed
|
||||
}
|
||||
return AttributedString(source)
|
||||
}
|
||||
|
||||
private static func preservingLineBreaks(_ source: String) -> String {
|
||||
let normalized = source
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
var result = ""
|
||||
var inFence = false
|
||||
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
for (index, rawLine) in lines.enumerated() {
|
||||
let line = String(rawLine)
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("```") {
|
||||
inFence.toggle()
|
||||
}
|
||||
result += line
|
||||
guard index < lines.count - 1 else { continue }
|
||||
if inFence || trimmed.isEmpty {
|
||||
result += "\n"
|
||||
} else {
|
||||
result += "\n\n"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,86 @@ import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RootView: View {
|
||||
@StateObject private var auth = AuthManager()
|
||||
@StateObject private var controller = PlayerController()
|
||||
@StateObject private var catalogVM = CatalogViewModel()
|
||||
@State private var activeModule: Module = .train
|
||||
@StateObject private var historyManager = TrainingHistoryManager()
|
||||
@State private var activeModule: Module = .course
|
||||
@State private var continuousPlayback = false
|
||||
@State private var showChineseSubtitles = false
|
||||
@State private var chineseTranslationLoading = false
|
||||
@State private var chineseTranslationError = ""
|
||||
@State private var chineseTranslationTask: Task<Void, Never>?
|
||||
@State private var showImporter = false
|
||||
@State private var showFullscreenPlayer = false
|
||||
@State private var courses: [Course] = []
|
||||
@State private var currentCourse: Course?
|
||||
@State private var courseStatusText = "正在同步"
|
||||
@State private var isRefreshingCourses = false
|
||||
@State private var showCourseGuardToast = false
|
||||
@State private var managedResults: [UserResultSummary] = []
|
||||
@State private var managedDubShares: [UserDubShareSummary] = []
|
||||
@State private var managementStatus = ""
|
||||
@State private var managementIsLoading = false
|
||||
@State private var deletingHistoryID: String?
|
||||
|
||||
enum Module {
|
||||
case course
|
||||
case train
|
||||
case test
|
||||
case mine
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if auth.isLoggedIn {
|
||||
mainContent
|
||||
} else {
|
||||
AuthView(auth: auth) {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
moduleTabs
|
||||
if activeModule == .train {
|
||||
switch activeModule {
|
||||
case .course:
|
||||
coursePage
|
||||
case .train:
|
||||
TrainingView(
|
||||
controller: controller,
|
||||
catalogVM: catalogVM,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
showChineseSubtitles: $showChineseSubtitles,
|
||||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||||
onFullscreen: enterFullscreen
|
||||
)
|
||||
} else {
|
||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
||||
case .test:
|
||||
TestView(
|
||||
controller: controller,
|
||||
historyManager: historyManager,
|
||||
onFullscreen: enterFullscreen
|
||||
)
|
||||
case .mine:
|
||||
minePage
|
||||
}
|
||||
}
|
||||
|
||||
if showCourseGuardToast {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("请先选择课程")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Spacer().frame(height: 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,10 +90,31 @@ struct RootView: View {
|
||||
.onAppear {
|
||||
catalogVM.controller = controller
|
||||
controller.setContinuousPlayback(continuousPlayback)
|
||||
if auth.isLoggedIn && courses.isEmpty {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
.onChange(of: showChineseSubtitles) { enabled in
|
||||
if enabled {
|
||||
ensureChineseTranslations()
|
||||
} else {
|
||||
chineseTranslationTask?.cancel()
|
||||
chineseTranslationLoading = false
|
||||
}
|
||||
}
|
||||
.onChange(of: controller.currentItem?.id) { _ in
|
||||
ensureChineseTranslations()
|
||||
}
|
||||
.onChange(of: controller.currentItem?.sentences.count) { _ in
|
||||
ensureChineseTranslations()
|
||||
}
|
||||
.onChange(of: activeModule) { module in
|
||||
if module == .train {
|
||||
controller.setContinuousPlayback(continuousPlayback)
|
||||
ensureChineseTranslations()
|
||||
} else if module == .test {
|
||||
// 与安卓测试页一致:按当前句窗口播放,避免滑进下一句。
|
||||
controller.setContinuousPlayback(false)
|
||||
} else {
|
||||
controller.setContinuousPlayback(true)
|
||||
}
|
||||
@@ -56,11 +130,21 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(catalogVM.$activeVideoHash) { videoHash in
|
||||
syncCurrentCourse(with: videoHash)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
||||
FullscreenPlayerView(controller: controller) {
|
||||
FullscreenPlayerView(
|
||||
controller: controller,
|
||||
showsLessonTitle: activeModule != .train,
|
||||
showChineseSubtitles: showChineseSubtitles,
|
||||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder
|
||||
) {
|
||||
exitFullscreen()
|
||||
}
|
||||
.onAppear(perform: enterFullscreen)
|
||||
.onAppear {
|
||||
AppDelegate.lockOrientation(.landscape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,18 +154,24 @@ struct RootView: View {
|
||||
Text("八哥口语")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Text("外语跟读训练神器")
|
||||
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
}
|
||||
Spacer()
|
||||
headerButton("导入") {
|
||||
showImporter = true
|
||||
if activeModule == .train {
|
||||
headerButton("导入") {
|
||||
showImporter = true
|
||||
}
|
||||
}
|
||||
headerButton("刷新") {
|
||||
Task {
|
||||
await catalogVM.load()
|
||||
}
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
headerButton("退出") {
|
||||
auth.logout()
|
||||
currentCourse = nil
|
||||
courses = []
|
||||
activeModule = .course
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
@@ -105,50 +195,495 @@ struct RootView: View {
|
||||
|
||||
private var moduleTabs: some View {
|
||||
HStack(spacing: 0) {
|
||||
moduleTab("课程", module: .course)
|
||||
moduleTab("训练", module: .train)
|
||||
moduleTab("测试", module: .test)
|
||||
moduleTab("我的", module: .mine)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||
let selected = activeModule == module
|
||||
return Button {
|
||||
activeModule = module
|
||||
Button {
|
||||
if module != .course && currentCourse == nil {
|
||||
showCourseGuardToast = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showCourseGuardToast = false
|
||||
}
|
||||
} else {
|
||||
activeModule = module
|
||||
}
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
||||
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
||||
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
// MARK: - Course Page
|
||||
|
||||
private var coursePage: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("课程首页")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
HStack(spacing: 10) {
|
||||
Text("选择一门课程开始训练")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await loadCourses() }
|
||||
} label: {
|
||||
Text(isRefreshingCourses ? "刷新中" : "刷新课程")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 32)
|
||||
.background(Theme.buttonBlue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isRefreshingCourses)
|
||||
.opacity(isRefreshingCourses ? 0.6 : 1)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 4)
|
||||
Text(courseStatusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if courses.isEmpty {
|
||||
Text("暂无课程")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(courses) { course in
|
||||
courseCard(course)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func courseCard(_ course: Course) -> some View {
|
||||
let isSelected = currentCourse?.videoHash == course.videoHash
|
||||
return Button {
|
||||
selectCourse(course)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(course.title)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(isSelected ? Theme.textDark : .white)
|
||||
.lineLimit(2)
|
||||
Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(isSelected ? Theme.selected : Theme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func selectCourse(_ course: Course) {
|
||||
currentCourse = course
|
||||
let video = course.toVideoSummary()
|
||||
catalogVM.select(video)
|
||||
activeModule = .train
|
||||
}
|
||||
|
||||
/// 训练页的课程卡片直接操作 CatalogViewModel;
|
||||
/// 这里监听它的 activeVideoHash,保证返回课程页时高亮和“当前课程”同步。
|
||||
private func syncCurrentCourse(with videoHash: String?) {
|
||||
guard let videoHash else { return }
|
||||
if let course = courses.first(where: { $0.videoHash == videoHash }) {
|
||||
currentCourse = course
|
||||
return
|
||||
}
|
||||
|
||||
// 本地导入视频不在 /courses 列表里;仍建立当前课程信息,避免课程页状态回退。
|
||||
if let video = catalogVM.videos.first(where: { $0.videoHash == videoHash }) {
|
||||
currentCourse = Course(
|
||||
videoHash: video.videoHash,
|
||||
title: video.title,
|
||||
durationMs: video.durationMs,
|
||||
language: video.language,
|
||||
sentenceCount: video.sentenceCount,
|
||||
streamUrl: video.streamUrl,
|
||||
enrolled: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mine Page
|
||||
|
||||
private var minePage: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname)
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
if let course = currentCourse {
|
||||
Text("当前课程:\(course.title)")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
Text("此页面显示当前课程的测试记录和配音分享")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await loadManagedHistory() }
|
||||
} label: {
|
||||
Text(managementIsLoading ? "刷新中..." : "刷新记录")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 36)
|
||||
.background(Theme.buttonBlue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(managementIsLoading)
|
||||
|
||||
if !managementStatus.isEmpty {
|
||||
Text(managementStatus)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
historyGroup(title: "我的测试结果", isEmpty: managedResults.isEmpty) {
|
||||
ForEach(managedResults) { row in
|
||||
resultRow(row)
|
||||
}
|
||||
}
|
||||
|
||||
historyGroup(title: "我的配音分享", isEmpty: managedDubShares.isEmpty) {
|
||||
ForEach(managedDubShares) { row in
|
||||
dubShareRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.task(id: currentCourse?.videoHash) {
|
||||
await loadManagedHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private func historyGroup<Content: View>(
|
||||
title: String,
|
||||
isEmpty: Bool,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
if isEmpty {
|
||||
Text("该课程暂无记录")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
LazyVStack(spacing: 8, content: content)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resultRow(_ row: UserResultSummary) -> some View {
|
||||
historyRow(
|
||||
title: "第 \(row.sentenceIndex + 1) 句 · \(String(format: "%.0f", row.overallScore)) 分",
|
||||
subtitle: "\(row.passed ? "通过" : "未通过") · \(displayDate(row.createdAt))\n\(row.sentenceText)",
|
||||
isDeleting: deletingHistoryID == row.id,
|
||||
deleteLabel: "删除"
|
||||
) {
|
||||
await deleteResult(row)
|
||||
}
|
||||
}
|
||||
|
||||
private func dubShareRow(_ row: UserDubShareSummary) -> some View {
|
||||
historyRow(
|
||||
title: row.title,
|
||||
subtitle: "\(row.segmentCount) 句 · 平均 \(String(format: "%.0f", row.averageScore)) 分 · \(displayDate(row.createdAt))",
|
||||
isDeleting: deletingHistoryID == row.id,
|
||||
deleteLabel: "删除"
|
||||
) {
|
||||
await deleteDubShare(row)
|
||||
}
|
||||
}
|
||||
|
||||
private func historyRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
isDeleting: Bool,
|
||||
deleteLabel: String,
|
||||
action: @escaping () async -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await action() }
|
||||
} label: {
|
||||
Text(isDeleting ? "删除中..." : deleteLabel)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 28)
|
||||
.background(isDeleting ? Theme.border : Theme.danger)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Theme.lightBorder, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func loadManagedHistory() async {
|
||||
guard let course = currentCourse else {
|
||||
managementStatus = "请先选择课程"
|
||||
managedResults = []
|
||||
managedDubShares = []
|
||||
return
|
||||
}
|
||||
managementIsLoading = true
|
||||
managementStatus = "正在加载..."
|
||||
defer { managementIsLoading = false }
|
||||
do {
|
||||
async let resultsTask = UserHistoryAPI.results(token: auth.token)
|
||||
async let sharesTask = UserHistoryAPI.dubShares(token: auth.token)
|
||||
let (allResults, allShares) = try await (resultsTask, sharesTask)
|
||||
managedResults = allResults.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
|
||||
managedDubShares = allShares.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
|
||||
managementStatus = "共 \(managedResults.count) 条测试、\(managedDubShares.count) 条配音"
|
||||
} catch {
|
||||
managementStatus = "记录加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteResult(_ row: UserResultSummary) async {
|
||||
guard let course = currentCourse, deletingHistoryID == nil else { return }
|
||||
deletingHistoryID = row.id
|
||||
defer { deletingHistoryID = nil }
|
||||
do {
|
||||
try await UserHistoryAPI.deleteResult(token: auth.token, attemptId: row.attemptId)
|
||||
managedResults.removeAll { $0.id == row.id }
|
||||
historyManager.store.removeScore(videoHash: course.videoHash, sentenceIndex: row.sentenceIndex)
|
||||
historyManager.notifyHistoryChanged()
|
||||
managementStatus = "测试记录已删除"
|
||||
} catch {
|
||||
managementStatus = "删除失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteDubShare(_ row: UserDubShareSummary) async {
|
||||
guard let course = currentCourse, deletingHistoryID == nil else { return }
|
||||
deletingHistoryID = row.id
|
||||
defer { deletingHistoryID = nil }
|
||||
do {
|
||||
try await UserHistoryAPI.deleteDubShare(token: auth.token, shareId: row.shareId)
|
||||
managedDubShares.removeAll { $0.id == row.id }
|
||||
historyManager.store.removeAllAudio(videoHash: course.videoHash)
|
||||
historyManager.notifyHistoryChanged()
|
||||
managementStatus = "配音分享已删除"
|
||||
} catch {
|
||||
managementStatus = "删除失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func displayDate(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "T", with: " ").prefix(16).description
|
||||
}
|
||||
|
||||
// MARK: - Load Courses
|
||||
|
||||
private func loadCourses() async {
|
||||
guard !isRefreshingCourses else { return }
|
||||
isRefreshingCourses = true
|
||||
defer { isRefreshingCourses = false }
|
||||
courseStatusText = "正在同步"
|
||||
do {
|
||||
var fetched = try await AuthAPI.courses(token: auth.token)
|
||||
var boundarySucceeded = 0
|
||||
var boundaryFailed = 0
|
||||
courses = fetched
|
||||
if !fetched.isEmpty {
|
||||
courseStatusText = "\(fetched.count) 个课程,正在刷新句界"
|
||||
let refreshed = await catalogVM.preloadBoundaries(
|
||||
for: fetched.map { $0.toVideoSummary() }
|
||||
)
|
||||
boundarySucceeded = refreshed.succeeded
|
||||
boundaryFailed = refreshed.failed
|
||||
let refreshedByHash = Dictionary(uniqueKeysWithValues: refreshed.videos.map { ($0.videoHash, $0) })
|
||||
fetched = fetched.map { course in
|
||||
guard let video = refreshedByHash[course.videoHash] else { return course }
|
||||
return Course(
|
||||
videoHash: course.videoHash,
|
||||
title: course.title,
|
||||
durationMs: video.durationMs,
|
||||
language: course.language,
|
||||
sentenceCount: video.sentenceCount,
|
||||
streamUrl: course.streamUrl,
|
||||
enrolled: course.enrolled
|
||||
)
|
||||
}
|
||||
if currentCourse != nil,
|
||||
let video = refreshed.videos.first(where: { $0.videoHash == currentCourse?.videoHash }) {
|
||||
currentCourse = fetched.first(where: { $0.videoHash == video.videoHash })
|
||||
}
|
||||
}
|
||||
courses = fetched
|
||||
if fetched.isEmpty {
|
||||
courseStatusText = "0 个课程"
|
||||
} else if boundaryFailed == 0 {
|
||||
courseStatusText = "\(fetched.count) 个课程 · 句界已刷新"
|
||||
} else if boundarySucceeded == 0 {
|
||||
courseStatusText = "\(fetched.count) 个课程 · 句界刷新失败"
|
||||
} else {
|
||||
courseStatusText = "\(fetched.count) 个课程 · 句界部分刷新(失败 \(boundaryFailed))"
|
||||
}
|
||||
} catch {
|
||||
courseStatusText = "课程加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fullscreen
|
||||
|
||||
private func enterFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||
updateSupportedInterfaceOrientations()
|
||||
AppDelegate.lockOrientation(.landscape)
|
||||
showFullscreenPlayer = true
|
||||
}
|
||||
|
||||
private func exitFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||||
updateSupportedInterfaceOrientations()
|
||||
let restored: UIInterfaceOrientationMask =
|
||||
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||||
AppDelegate.lockOrientation(restored)
|
||||
showFullscreenPlayer = false
|
||||
}
|
||||
|
||||
private func updateSupportedInterfaceOrientations() {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
let controllers = scenes.flatMap { $0.windows }.compactMap(\.rootViewController)
|
||||
for controller in controllers {
|
||||
if #available(iOS 16.0, *) {
|
||||
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
} else {
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
private var chineseSubtitlePlaceholder: String {
|
||||
if chineseTranslationLoading {
|
||||
return "正在翻译中文字幕…"
|
||||
}
|
||||
if !chineseTranslationError.isEmpty {
|
||||
return chineseTranslationError
|
||||
}
|
||||
return "暂无中文字幕"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyChineseTranslations(_ map: [Int: String], mediaId: String) {
|
||||
guard controller.currentItem?.id == mediaId else { return }
|
||||
let updated = (controller.currentItem?.sentences ?? []).map { sentence in
|
||||
sentence.withZhText(map[sentence.index] ?? sentence.zhText)
|
||||
}
|
||||
controller.updateBoundaries(updated)
|
||||
}
|
||||
|
||||
private func ensureChineseTranslations() {
|
||||
guard showChineseSubtitles, activeModule == .train else {
|
||||
return
|
||||
}
|
||||
guard let item = controller.currentItem else {
|
||||
return
|
||||
}
|
||||
let needsTranslation = item.sentences.contains {
|
||||
!($0.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
($0.zhText ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
guard needsTranslation else {
|
||||
chineseTranslationLoading = false
|
||||
chineseTranslationError = ""
|
||||
return
|
||||
}
|
||||
chineseTranslationTask?.cancel()
|
||||
chineseTranslationLoading = true
|
||||
chineseTranslationError = ""
|
||||
let mediaId = item.id
|
||||
chineseTranslationTask = Task {
|
||||
do {
|
||||
let expectedIndexes = item.sentences.compactMap { sentence -> Int? in
|
||||
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : sentence.index
|
||||
}
|
||||
let map = try await SentenceBoundaryAPI.loadSubtitles(
|
||||
videoHash: mediaId,
|
||||
expectedIndexes: expectedIndexes
|
||||
) { partial in
|
||||
Task { @MainActor in
|
||||
applyChineseTranslations(partial, mediaId: mediaId)
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
applyChineseTranslations(map, mediaId: mediaId)
|
||||
chineseTranslationLoading = false
|
||||
chineseTranslationError = ""
|
||||
controller.showTransientMessage("已加载中文字幕")
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
chineseTranslationLoading = false
|
||||
chineseTranslationError = "中文字幕翻译失败"
|
||||
controller.showTransientMessage("中文字幕翻译失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import SwiftUI
|
||||
|
||||
struct TestView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
@ObservedObject var historyManager: TrainingHistoryManager
|
||||
let onFullscreen: () -> Void
|
||||
|
||||
@State private var recorder: AVAudioRecorder?
|
||||
@State private var recordingSession: AudioRecorderSession?
|
||||
@State private var recordingURL: URL?
|
||||
@State private var isRecording = false
|
||||
@State private var isAssessing = false
|
||||
@@ -13,6 +14,7 @@ struct TestView: View {
|
||||
@State private var scoreSummary = "尚未评测"
|
||||
@State private var scoreDetail = ""
|
||||
@State private var dubSegments: [Int: URL] = [:]
|
||||
@State private var dubRawSegments: [Int: URL] = [:]
|
||||
@State private var latestScores: [Int: ImitationAssessmentResult] = [:]
|
||||
@State private var mergedDubURL: URL?
|
||||
@State private var dubPlayer: AVPlayer?
|
||||
@@ -24,6 +26,8 @@ struct TestView: View {
|
||||
@State private var showShareSheet = false
|
||||
@State private var dubPlaybackObserver: NSObjectProtocol?
|
||||
@State private var testSubtitleVisible = true
|
||||
@State private var loadedCourseHash: String?
|
||||
@State private var remoteHistoryGeneration = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -31,7 +35,7 @@ struct TestView: View {
|
||||
PlayerSectionView(
|
||||
controller: controller,
|
||||
showsContinuousToggle: false,
|
||||
continuousPlayback: .constant(true),
|
||||
continuousPlayback: .constant(false),
|
||||
showsSubtitle: $testSubtitleVisible,
|
||||
onFullscreen: onFullscreen
|
||||
)
|
||||
@@ -57,13 +61,16 @@ struct TestView: View {
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
sentenceButton("上一句") {
|
||||
stopDubPlayback(message: nil)
|
||||
controller.seekToPreviousSentence()
|
||||
}
|
||||
sentenceButton("播放原句") {
|
||||
stopDubPlayback(message: nil)
|
||||
controller.playCurrentSentenceAndStop()
|
||||
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
|
||||
}
|
||||
sentenceButton("下一句") {
|
||||
stopDubPlayback(message: nil)
|
||||
controller.seekToNextSentence()
|
||||
}
|
||||
}
|
||||
@@ -79,19 +86,24 @@ struct TestView: View {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAssessing)
|
||||
HStack(spacing: 8) {
|
||||
dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||
dubButton(
|
||||
dubbingPlayback ? "停止播放" : "播放本句录音",
|
||||
enabled: dubbingPlayback || (controller.currentSentence.flatMap { dubSegments[$0.index] } != nil)
|
||||
) {
|
||||
toggleDubPlayback()
|
||||
}
|
||||
dubButton("生成配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||
dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||
stopDubPlayback(message: nil)
|
||||
generateDubShareAudio()
|
||||
}
|
||||
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
|
||||
stopDubPlayback(message: nil)
|
||||
shareMergedDub()
|
||||
}
|
||||
}
|
||||
Text(dubbingStatus)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentDeep)
|
||||
.foregroundColor(isSharingDub ? Theme.danger : Theme.accentDeep)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(scoreSummary)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
@@ -112,7 +124,8 @@ struct TestView: View {
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.onReceive(controller.$currentItem) { _ in
|
||||
.onReceive(controller.$currentItem) { item in
|
||||
synchronizeHistory(for: item?.id)
|
||||
refreshTestUI()
|
||||
}
|
||||
.onReceive(controller.$currentSentence) { _ in
|
||||
@@ -121,6 +134,14 @@ struct TestView: View {
|
||||
.onDisappear {
|
||||
stopDubPlayback(message: nil)
|
||||
}
|
||||
.onAppear {
|
||||
synchronizeHistory(for: controller.currentItem?.id)
|
||||
refreshTestUI()
|
||||
}
|
||||
.onChange(of: historyManager.historyVersion) { _ in
|
||||
synchronizeHistory(for: controller.currentItem?.id, forceLocalReload: true)
|
||||
refreshTestUI()
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let url = shareURL {
|
||||
ActivityShareSheet(items: [url])
|
||||
@@ -158,6 +179,7 @@ struct TestView: View {
|
||||
// MARK: - 录音
|
||||
|
||||
private func toggleRecording() {
|
||||
stopDubPlayback(message: nil)
|
||||
if isRecording {
|
||||
stopRecordingAndAssess()
|
||||
} else {
|
||||
@@ -195,60 +217,111 @@ struct TestView: View {
|
||||
private func beginRecording() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 96_000,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
|
||||
]
|
||||
let newSession = AudioRecorderSession(outputURL: fileURL)
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
let newRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
|
||||
guard newRecorder.record() else {
|
||||
testStatus = "录音启动失败:无法开始录制"
|
||||
guard session.isInputAvailable else {
|
||||
testStatus = microphoneInputUnavailableMessage()
|
||||
return
|
||||
}
|
||||
recorder = newRecorder
|
||||
do {
|
||||
try newSession.start()
|
||||
} catch {
|
||||
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
try newSession.start()
|
||||
}
|
||||
recordingSession = newSession
|
||||
recordingURL = fileURL
|
||||
isRecording = true
|
||||
testStatus = "正在录音…读完当前句子后点击“停止并评测”"
|
||||
} catch AudioRecorderSessionError.inputUnavailable {
|
||||
newSession.cancel()
|
||||
testStatus = microphoneInputUnavailableMessage()
|
||||
} catch AudioRecorderSessionError.startFailed {
|
||||
newSession.cancel()
|
||||
testStatus = recordingStartFailedMessage()
|
||||
} catch {
|
||||
newSession.cancel()
|
||||
testStatus = "录音启动失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func microphoneInputUnavailableMessage() -> String {
|
||||
#if targetEnvironment(simulator)
|
||||
return "录音启动失败:未检测到麦克风输入。请在模拟器菜单 I/O → Audio Input 中选择输入设备,并允许“模拟器”使用 Mac 麦克风后重试"
|
||||
#else
|
||||
return "录音启动失败:未检测到麦克风输入。请检查 设置 → 隐私与安全性 → 麦克风 是否允许本 App 使用麦克风"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func recordingStartFailedMessage() -> String {
|
||||
#if targetEnvironment(simulator)
|
||||
return "录音启动失败:无法开始录制。模拟器请确认 I/O → Audio Input 已选择输入设备,再重试"
|
||||
#else
|
||||
return "录音启动失败:无法开始录制。请确认麦克风未被其他应用占用后重试"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func stopRecordingAndAssess() {
|
||||
guard let currentRecorder = recorder else {
|
||||
guard let currentSession = recordingSession else {
|
||||
return
|
||||
}
|
||||
let duration: TimeInterval
|
||||
do {
|
||||
try currentSession.stop()
|
||||
duration = currentSession.recordDuration
|
||||
} catch {
|
||||
testStatus = "录音结束失败:\(error.localizedDescription)"
|
||||
currentSession.cancel()
|
||||
recordingSession = nil
|
||||
isRecording = false
|
||||
restorePlaybackAudioSession()
|
||||
return
|
||||
}
|
||||
let duration = currentRecorder.currentTime
|
||||
currentRecorder.stop()
|
||||
restorePlaybackAudioSession()
|
||||
recorder = nil
|
||||
recordingSession = nil
|
||||
isRecording = false
|
||||
guard let url = recordingURL else {
|
||||
return
|
||||
}
|
||||
recordingURL = nil
|
||||
let rawURL = currentSession.rawRecordingURL
|
||||
if duration < 1.0 {
|
||||
testStatus = "录音太短或无法保存"
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
try? FileManager.default.removeItem(at: rawURL)
|
||||
return
|
||||
}
|
||||
guard let sentence = controller.currentSentence else {
|
||||
testStatus = "没有可归属的句子,本次录音已丢弃"
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
try? FileManager.default.removeItem(at: rawURL)
|
||||
return
|
||||
}
|
||||
if let oldURL = dubSegments[sentence.index] {
|
||||
try? FileManager.default.removeItem(at: oldURL)
|
||||
var audioURL = url
|
||||
var rawPersistedURL = rawURL
|
||||
if let item = controller.currentItem {
|
||||
do {
|
||||
let persisted = try historyManager.store.saveSegment(
|
||||
videoHash: item.id,
|
||||
sentenceIndex: sentence.index,
|
||||
audioURL: url,
|
||||
rawURL: rawURL
|
||||
)
|
||||
audioURL = persisted.audioURL
|
||||
rawPersistedURL = persisted.rawURL
|
||||
} catch {
|
||||
testStatus = "录音已评测,但本地历史保存失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
dubSegments[sentence.index] = url
|
||||
dubSegments[sentence.index] = audioURL
|
||||
dubRawSegments[sentence.index] = rawPersistedURL
|
||||
mergedDubURL = nil
|
||||
submitAssessment(recordingURL: url)
|
||||
submitAssessment(recordingURL: audioURL)
|
||||
}
|
||||
|
||||
private func restorePlaybackAudioSession() {
|
||||
@@ -286,13 +359,14 @@ struct TestView: View {
|
||||
Task {
|
||||
do {
|
||||
let result = try await ImitationAssessor.assess(request: request)
|
||||
latestScores[sentence.index] = result
|
||||
historyManager.store.saveScores(videoHash: item.id, scores: latestScores)
|
||||
applyAssessmentResult(result)
|
||||
} catch {
|
||||
testStatus = "评测失败:\(error.localizedDescription)"
|
||||
scoreSummary = "评测失败"
|
||||
}
|
||||
isAssessing = false
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,44 +415,197 @@ struct TestView: View {
|
||||
testStatus = "请先在训练模块选择一个云端课程"
|
||||
return
|
||||
}
|
||||
let currentRecording = dubSegments[sentence.index]
|
||||
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
||||
if mergedDubURL == nil {
|
||||
if let score = latestScores[sentence.index] {
|
||||
applyAssessmentResult(score)
|
||||
} else {
|
||||
let currentRecording = dubSegments[sentence.index]
|
||||
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
||||
scoreSummary = "尚未评测"
|
||||
scoreDetail = ""
|
||||
}
|
||||
if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub {
|
||||
dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 历史记录
|
||||
|
||||
private func synchronizeHistory(for videoHash: String?, forceLocalReload: Bool = false) {
|
||||
guard let videoHash else {
|
||||
loadedCourseHash = nil
|
||||
dubSegments = [:]
|
||||
dubRawSegments = [:]
|
||||
latestScores = [:]
|
||||
return
|
||||
}
|
||||
let normalizedHash = historyManager.store.normalizedVideoHash(videoHash)
|
||||
guard loadedCourseHash != normalizedHash || forceLocalReload else { return }
|
||||
|
||||
loadedCourseHash = normalizedHash
|
||||
remoteHistoryGeneration += 1
|
||||
let generation = remoteHistoryGeneration
|
||||
let snapshot = historyManager.store.load(videoHash: videoHash)
|
||||
dubSegments = snapshot.audioSegments
|
||||
dubRawSegments = snapshot.rawSegments
|
||||
for (index, audioURL) in snapshot.audioSegments where dubRawSegments[index] == nil {
|
||||
dubRawSegments[index] = audioURL
|
||||
}
|
||||
latestScores = snapshot.scores
|
||||
mergedDubURL = nil
|
||||
if snapshot.audioSegments.isEmpty && snapshot.scores.isEmpty {
|
||||
dubbingStatus = "正在同步历史配音…"
|
||||
} else {
|
||||
dubbingStatus = "已加载历史配音 \(snapshot.audioSegments.count) 句、评分 \(snapshot.scores.count) 句"
|
||||
}
|
||||
|
||||
Task {
|
||||
await restoreRemoteHistory(videoHash: videoHash, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreRemoteHistory(videoHash: String, generation: Int) async {
|
||||
let token = AppConfig.authToken
|
||||
guard !token.isEmpty else { return }
|
||||
let normalizedHash = historyManager.store.normalizedVideoHash(videoHash)
|
||||
|
||||
do {
|
||||
let rows = try await UserHistoryAPI.results(token: token)
|
||||
guard remoteHistoryGeneration == generation,
|
||||
loadedCourseHash == normalizedHash else { return }
|
||||
var latest = [Int: UserResultSummary]()
|
||||
rows.filter { historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
.forEach { latest[$0.sentenceIndex] = $0 }
|
||||
|
||||
var addedScores = 0
|
||||
for (index, row) in latest where latestScores[index] == nil {
|
||||
if let score = decodedScore(payload: [
|
||||
"overall_score": row.overallScore,
|
||||
"passed": row.passed,
|
||||
"reference_text": row.sentenceText,
|
||||
"feedback": "来自历史测试记录",
|
||||
]) {
|
||||
latestScores[index] = score
|
||||
addedScores += 1
|
||||
}
|
||||
}
|
||||
if addedScores > 0 {
|
||||
historyManager.store.saveScores(videoHash: videoHash, scores: latestScores)
|
||||
dubbingStatus = "已同步历史评分 \(latestScores.count) 句"
|
||||
refreshTestUI()
|
||||
}
|
||||
} catch {
|
||||
if remoteHistoryGeneration == generation, latestScores.isEmpty {
|
||||
dubbingStatus = "历史评分同步失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
guard dubSegments.isEmpty else { return }
|
||||
do {
|
||||
let shares = try await UserHistoryAPI.dubShares(token: token)
|
||||
guard remoteHistoryGeneration == generation,
|
||||
loadedCourseHash == normalizedHash else { return }
|
||||
guard let share = shares
|
||||
.filter({ historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash })
|
||||
.max(by: { $0.createdAt < $1.createdAt }) else {
|
||||
if dubSegments.isEmpty && latestScores.isEmpty {
|
||||
dubbingStatus = "暂无可用的历史配音音频"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let detail = try await UserHistoryAPI.dubShareDetail(token: token, shareId: share.shareId)
|
||||
var restored = [Int: URL]()
|
||||
for segment in detail.segments {
|
||||
guard remoteHistoryGeneration == generation else { return }
|
||||
let filename = segment.audioURL.split(separator: "/").last.map(String.init) ?? ""
|
||||
guard !filename.isEmpty else { continue }
|
||||
let destination = historyManager.store.audioDestination(
|
||||
videoHash: videoHash,
|
||||
sentenceIndex: segment.index
|
||||
)
|
||||
try await UserHistoryAPI.downloadAudio(
|
||||
token: token,
|
||||
audioPath: "api/v1/dub-shares/\(share.shareId)/audio/\(filename)",
|
||||
destination: destination
|
||||
)
|
||||
restored[segment.index] = destination
|
||||
var payload: [String: Any] = [
|
||||
"overall_score": segment.overallScore,
|
||||
"feedback": "来自历史配音分享",
|
||||
]
|
||||
if !segment.recognizedText.isEmpty {
|
||||
payload["recognized_text"] = segment.recognizedText
|
||||
}
|
||||
segment.scoreDetails.forEach { payload[$0.key] = $0.value }
|
||||
if let score = decodedScore(payload: payload), latestScores[segment.index] == nil {
|
||||
latestScores[segment.index] = score
|
||||
}
|
||||
}
|
||||
|
||||
guard remoteHistoryGeneration == generation,
|
||||
loadedCourseHash == normalizedHash else { return }
|
||||
for (index, audioURL) in restored where dubSegments[index] == nil {
|
||||
dubSegments[index] = audioURL
|
||||
dubRawSegments[index] = audioURL
|
||||
}
|
||||
historyManager.store.saveScores(videoHash: videoHash, scores: latestScores)
|
||||
if !restored.isEmpty {
|
||||
dubbingStatus = "已从云端恢复配音 \(restored.count) 句"
|
||||
} else if dubSegments.isEmpty && latestScores.isEmpty {
|
||||
dubbingStatus = "暂无可用的历史配音音频"
|
||||
}
|
||||
refreshTestUI()
|
||||
} catch {
|
||||
guard remoteHistoryGeneration == generation, dubSegments.isEmpty else { return }
|
||||
dubbingStatus = "历史配音同步失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func decodedScore(payload: [String: Any]) -> ImitationAssessmentResult? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
return try? JSONDecoder().decode(ImitationAssessmentResult.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - 配音
|
||||
|
||||
private func toggleDubPlayback() {
|
||||
if dubbingPlayback {
|
||||
stopDubPlayback(message: "已停止配音播放")
|
||||
stopDubPlayback(message: "已停止录音播放")
|
||||
return
|
||||
}
|
||||
let sentence = controller.currentSentence
|
||||
let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] }
|
||||
guard let audioURL else { return }
|
||||
if mergedDubURL == nil, let sentence = sentence {
|
||||
controller.seek(to: sentence.startMs)
|
||||
guard let sentence = controller.currentSentence else {
|
||||
dubbingStatus = "请先选择要播放的句子"
|
||||
return
|
||||
}
|
||||
guard let audioURL = dubSegments[sentence.index] else {
|
||||
dubbingStatus = "当前句还没有录音,请先录音后再播放"
|
||||
return
|
||||
}
|
||||
// Always play the current sentence recording only (never the merged full track).
|
||||
stopDubPlayback(message: nil)
|
||||
controller.seek(to: sentence.startMs)
|
||||
controller.pause()
|
||||
controller.setVolume(0)
|
||||
let player = dubPlayer ?? AVPlayer()
|
||||
dubPlayer = player
|
||||
player.replaceCurrentItem(with: AVPlayerItem(url: audioURL))
|
||||
let item = AVPlayerItem(url: audioURL)
|
||||
player.replaceCurrentItem(with: item)
|
||||
dubbingPlayback = true
|
||||
dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音"
|
||||
dubbingStatus = "正在播放当前句录音"
|
||||
player.play()
|
||||
if dubPlaybackObserver == nil {
|
||||
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: player.currentItem,
|
||||
queue: .main
|
||||
) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
stopDubPlayback(message: "配音播放完成")
|
||||
}
|
||||
if let dubPlaybackObserver {
|
||||
NotificationCenter.default.removeObserver(dubPlaybackObserver)
|
||||
self.dubPlaybackObserver = nil
|
||||
}
|
||||
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
stopDubPlayback(message: "本句录音播放完成")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,8 +626,8 @@ struct TestView: View {
|
||||
}
|
||||
|
||||
private func generateDubShareAudio() {
|
||||
guard !dubSegments.isEmpty else { return }
|
||||
let segments = dubSegments.sorted { $0.key < $1.key }
|
||||
guard !dubRawSegments.isEmpty else { return }
|
||||
let segments = dubRawSegments.sorted { $0.key < $1.key }
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
|
||||
isGeneratingDub = true
|
||||
@@ -427,7 +654,7 @@ struct TestView: View {
|
||||
return
|
||||
}
|
||||
isSharingDub = true
|
||||
dubbingStatus = "正在生成分享链接…"
|
||||
dubbingStatus = "正在生成分享链接,请稍候..."
|
||||
let request = DubShareUploadRequest(
|
||||
videoHash: item.id,
|
||||
title: item.title,
|
||||
|
||||
@@ -13,9 +13,11 @@ enum Theme {
|
||||
static let progressTrackDark = Color(hex: 0x373E46)
|
||||
static let lightBorder = Color(hex: 0xDAE2E8)
|
||||
static let overlay = Color(red: 9.0 / 255.0, green: 12.0 / 255.0, blue: 16.0 / 255.0, opacity: 0.7)
|
||||
static let overlayFaint = Color(red: 9.0 / 255.0, green: 12.0 / 255.0, blue: 16.0 / 255.0, opacity: 0.2)
|
||||
static let textDark = Color(hex: 0x12181F)
|
||||
static let textMuted = Color(hex: 0x97A2AE)
|
||||
static let textSubtle = Color(hex: 0x58626C)
|
||||
static let danger = Color(hex: 0xDC2626)
|
||||
static let textOverlay = Color(hex: 0xA3ACB6)
|
||||
|
||||
static func formatTime(_ ms: Int64) -> String {
|
||||
|
||||
@@ -4,6 +4,8 @@ struct TrainingView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
@ObservedObject var catalogVM: CatalogViewModel
|
||||
@Binding var continuousPlayback: Bool
|
||||
@Binding var showChineseSubtitles: Bool
|
||||
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||
let onFullscreen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -13,6 +15,9 @@ struct TrainingView: View {
|
||||
controller: controller,
|
||||
showsContinuousToggle: true,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
showsLessonTitle: false,
|
||||
showChineseSubtitles: $showChineseSubtitles,
|
||||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||||
onFullscreen: onFullscreen
|
||||
)
|
||||
CatalogView(vm: catalogVM)
|
||||
@@ -27,5 +32,8 @@ struct TrainingView: View {
|
||||
: "已关闭连续播放,将循环播放当前句子"
|
||||
)
|
||||
}
|
||||
.onChange(of: showChineseSubtitles) { enabled in
|
||||
controller.showTransientMessage(enabled ? "已开启中文字幕" : "已关闭中文字幕")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
iOS 端与 [android/](../android/README.md) 示例 App 功能对齐的移动端:
|
||||
|
||||
- 在线视频目录(`GET /api/v1/videos`,仅展示 `ready` 课程,自动解析相对流地址)
|
||||
- AVPlayer 在线播放 + 本地缓存导入;单击播放/暂停
|
||||
- 云端视频先完整下载为本地整文件缓存,下载完成后才允许 AVPlayer 播放;
|
||||
支持本地缓存导入;单击播放/暂停
|
||||
- 左划 / 右划:上一句 / 下一句(无句边界时回退为 10 秒快退 / 快进)
|
||||
- 长按播放区:按触摸点水平位置变速(左慢右快,0.5x - 2.0x),松手恢复原速
|
||||
- 按句跳转:句边界来自 `GET /api/v1/videos/{sha256}/sentence-boundaries`
|
||||
@@ -59,4 +60,12 @@ ios/
|
||||
|
||||
- “云端暂不可用:…”:检查手机能否访问服务端、地址是否填对、网关证书是否有效。
|
||||
- 本地视频评测提示“仅云端课程支持朗读评测”:本地文件需先通过管理端上传到服务器。
|
||||
- 录音提示失败:在 设置 > 隐私 > 麦克风 中允许本 App 使用麦克风。
|
||||
- 录音提示“无法开始录制”:录音已改用 `AVAudioEngine` 采集并转码 AAC 16kHz
|
||||
(`AVAudioRecorder` 在 iOS 26 真机上存在 `record()` 恒返回 `false` 的已知问题)。
|
||||
若仍失败,真机请确认 设置 → 隐私与安全性 → 麦克风 已允许本 App 使用麦克风,
|
||||
且没有其他 App 正在占用麦克风;模拟器请先在 I/O → Audio Input 中选择输入设备
|
||||
(如 Mac 内置麦克风),并允许“模拟器”使用 Mac 麦克风。
|
||||
- 配音“合成失败”:iOS 26 / macOS 26 上 `AVAssetExportSession` 导出纯音频会失败
|
||||
(AVError -11800,底层 `'fmt?'`),`AVAudioFile` 读回 AAC 也会报 `'fmt?'`。
|
||||
配音合成已改为合并录音时保留的原始 PCM(caf)后直接编码 AAC 16kHz,与录音转码
|
||||
同链路;如仍失败,提示中会附带底层错误详情,可按详情排查。
|
||||
|
||||
2343
media_player.py
2343
media_player.py
File diff suppressed because it is too large
Load Diff
468
moss/README.md
Normal file
468
moss/README.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# MOSS-Transcribe-Diarize 转写服务部署
|
||||
|
||||
基于 [OpenMOSS/MOSS-Transcribe-Diarize](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize)
|
||||
0.9B 的端到端语音转写服务。单模型联合完成转写与说话人分离,输出带
|
||||
`[S01]`、`[S02]` 等说话人标签和精确时间戳的结构化转写结果,适合多人对话、
|
||||
会议、播客、访谈等多说话人场景。
|
||||
|
||||
与 Whisper 的区别:Whisper 只做语音转文字,不区分说话人;MOSS 在转写的
|
||||
同时输出说话人标签,`sentence_api` 无需额外集成即可自动按说话人分句。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
moss/
|
||||
README.md 本文档
|
||||
systemd/moss-transcribe.service systemd 服务模板
|
||||
verify.sh 部署验证脚本
|
||||
```
|
||||
|
||||
## 硬件与系统要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| GPU | NVIDIA,显存 ≥ 8 GB(推荐 16 GB 以上) |
|
||||
| CUDA | 12.9+(vLLM 方案)或 13.0+(SGLang Omni 方案) |
|
||||
| 驱动 | ≥ 535(支持 CUDA 12.x)或 ≥ 570(支持 CUDA 13) |
|
||||
| 系统 | Ubuntu 22.04 / 24.04 |
|
||||
| Python | 3.12 |
|
||||
| 其他 | FFmpeg、Git、curl |
|
||||
|
||||
模型权重约 2 GB(bf16),推理时加上音频编码器和 KV 缓存实际占用约 3–6 GB。
|
||||
如果与其他 GPU 服务共存,需确保剩余显存充足。
|
||||
|
||||
## 推理后端选择
|
||||
|
||||
MOSS 官方提供两种推理后端,按 CUDA 版本选择:
|
||||
|
||||
| 后端 | CUDA 要求 | 适用场景 |
|
||||
|------|-----------|----------|
|
||||
| **SGLang Omni**(推荐) | CUDA 13.0+ | 官方推荐后端,长音频性能更好 |
|
||||
| **vLLM** | CUDA 12.x / 13.x | CUDA 12 环境或 SGLang 安装遇到问题时使用 |
|
||||
|
||||
两者提供相同的 OpenAI 兼容接口(`POST /v1/audio/transcriptions`),
|
||||
切换后端不需要改 `sentence_api` 的任何配置。
|
||||
|
||||
---
|
||||
|
||||
## 方案 A:SGLang Omni 部署(CUDA 13,推荐)
|
||||
|
||||
### 前置条件
|
||||
|
||||
确认驱动支持 CUDA 13:
|
||||
|
||||
```bash
|
||||
nvidia-smi
|
||||
# 查看 CUDA Version 列,需 ≥ 13.0
|
||||
```
|
||||
|
||||
### 安装系统依赖
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ffmpeg git curl
|
||||
sudo apt install -y ninja-build
|
||||
```
|
||||
|
||||
### 安装 uv
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 重新加载 shell 或执行 source ~/.bashrc 使 uv 命令生效
|
||||
```
|
||||
|
||||
### 创建目录和虚拟环境
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/moss-transcribe
|
||||
sudo chown "$USER":"$USER" /opt/moss-transcribe
|
||||
cd /opt/moss-transcribe
|
||||
|
||||
git clone https://github.com/OpenMOSS/MOSS-Transcribe-Diarize.git
|
||||
cd MOSS-Transcribe-Diarize
|
||||
|
||||
uv venv --python 3.12 .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 安装推理框架
|
||||
|
||||
```bash
|
||||
uv pip install -e ".[torch-runtime]" --torch-backend=auto
|
||||
uv pip install sglang-omni
|
||||
uv pip install ninja
|
||||
```
|
||||
|
||||
> `ninja` 是 FlashInfer JIT 编译 CUDA kernel 所需的构建工具,缺少它会在启动时
|
||||
> 报 `FileNotFoundError: 'ninja'`。
|
||||
>
|
||||
> 必须用 `apt install ninja-build` 安装系统级 ninja(`/usr/bin/ninja`)。
|
||||
> `uv pip install ninja` 只安装到虚拟环境 `.venv/bin/`,systemd 服务的 PATH
|
||||
> 不包含它。如果只能用 venv 里的 ninja,在 systemd 服务的 `[Service]` 段加:
|
||||
>
|
||||
> ```ini
|
||||
> Environment=PATH=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
> ```
|
||||
|
||||
### 下载模型
|
||||
|
||||
```bash
|
||||
# 国内服务器使用 HF 镜像加速
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
hf download OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
```
|
||||
|
||||
模型下载到 `~/.cache/huggingface/hub/`,约 2 GB。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
source /opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin/activate
|
||||
|
||||
sgl-omni serve \
|
||||
--model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \
|
||||
--host 127.0.0.1 \
|
||||
--port 8001 \
|
||||
--mem-fraction-static 0.35
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--host` | `127.0.0.1` | 只监听本机,不要暴露公网 |
|
||||
| `--port` | `8001` | 服务端口(Whisper 用 9000,避免冲突) |
|
||||
| `--mem-fraction-static` | 自动 | 静态显存分配比例(作用于所有 SGLang engine stage);与其他 GPU 服务共存时调低 |
|
||||
|
||||
> 与 llama-server、Whisper 等其他 GPU 服务共存时,将
|
||||
> `--mem-fraction-static` 设为 `0.30`–`0.40`(约 7–10 GB),避免 OOM。
|
||||
> 独占 GPU 时可设为 `0.80` 获得最大并发能力。
|
||||
|
||||
首次启动会加载模型到 GPU,等待日志出现 `Uvicorn running on ...` 即启动完成。
|
||||
|
||||
---
|
||||
|
||||
## 方案 B:vLLM 部署(CUDA 12 / 13 兼容)
|
||||
|
||||
如果 CUDA 版本低于 13.0 或 SGLang Omni 安装遇到兼容性问题,改用 vLLM。
|
||||
|
||||
### 前置条件
|
||||
|
||||
同方案 A,但不要求 CUDA 13。
|
||||
|
||||
### 安装系统依赖和 uv
|
||||
|
||||
与方案 A 相同(见上文)。
|
||||
|
||||
### 创建目录和虚拟环境
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/moss-transcribe
|
||||
sudo chown "$USER":"$USER" /opt/moss-transcribe
|
||||
cd /opt/moss-transcribe
|
||||
|
||||
uv venv --python 3.12 .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 安装 vLLM
|
||||
|
||||
vLLM 从 0.27.1 起包含 MOSS-Transcribe-Diarize 的模型注册。
|
||||
|
||||
```bash
|
||||
# CUDA 13 环境
|
||||
uv pip install -U "vllm[audio]" --torch-backend=auto
|
||||
```
|
||||
|
||||
如果安装的 vLLM 版本不支持 MOSS 模型(启动时报 `model not found`),
|
||||
需要使用官方指定的 pinned nightly 构建:
|
||||
|
||||
```bash
|
||||
# CUDA 12 环境(cu129)
|
||||
uv pip install -U vllm \
|
||||
--torch-backend=auto \
|
||||
--extra-index-url https://wheels.vllm.ai/68b4a1d582818e67adc903bf1b8fc5a5447da2fa/cu129
|
||||
```
|
||||
|
||||
> 上游构建地址可能随版本更新,正式部署前对照
|
||||
> [MOSS 官方仓库](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize) Quickstart。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
source /opt/moss-transcribe/.venv/bin/activate
|
||||
|
||||
vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize \
|
||||
--host 127.0.0.1 \
|
||||
--port 8001 \
|
||||
--trust-remote-code
|
||||
```
|
||||
|
||||
> `--trust-remote-code` 是必需的:模型使用自定义 HuggingFace processor 代码。
|
||||
|
||||
首次启动会自动从 HuggingFace 下载模型权重。国内服务器建议在启动前设置:
|
||||
|
||||
```bash
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注册为 systemd 服务
|
||||
|
||||
### SGLang Omni 版本
|
||||
|
||||
复制服务文件到系统目录并修改实际路径:
|
||||
|
||||
```bash
|
||||
sudo cp /path/to/repo/moss/systemd/moss-transcribe.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
确认 `ExecStart` 路径与实际安装位置一致:
|
||||
|
||||
```ini
|
||||
ExecStart=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin/sgl-omni serve ...
|
||||
```
|
||||
|
||||
### vLLM 版本
|
||||
|
||||
将 `ExecStart` 改为:
|
||||
|
||||
```ini
|
||||
ExecStart=/opt/moss-transcribe/.venv/bin/vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize --host 127.0.0.1 --port 8001 --trust-remote-code
|
||||
```
|
||||
|
||||
并删除 `Environment=HF_ENDPOINT=...` 以外的 SGLang 特有参数。
|
||||
|
||||
### 启用并启动
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now moss-transcribe
|
||||
sudo journalctl -u moss-transcribe -f # 观察启动日志
|
||||
```
|
||||
|
||||
首次启动会下载模型权重,看到 `running on ...` 表示服务就绪。
|
||||
|
||||
---
|
||||
|
||||
## 验证部署
|
||||
|
||||
使用仓库自带的验证脚本:
|
||||
|
||||
```bash
|
||||
cd /opt/moss-transcribe
|
||||
bash verify.sh
|
||||
|
||||
# 用真实语音文件验证(推荐)
|
||||
bash verify.sh --file /path/to/multi-speaker.wav
|
||||
|
||||
# 同时检查 sentence_api 集成状态
|
||||
bash verify.sh --api-url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
或手动发起转写请求:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
|
||||
-F model=OpenMOSS-Team/MOSS-Transcribe-Diarize \
|
||||
-F file=@/path/to/test.wav \
|
||||
-F response_format=verbose_json \
|
||||
-F temperature=0 \
|
||||
-F max_new_tokens=65536
|
||||
```
|
||||
|
||||
**正常响应**应包含:
|
||||
|
||||
- `text`:完整转写文本
|
||||
- `segments`:分段数组,每段包含 `start`、`end`、`text`、`speaker`
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "大家好,今天我们讨论项目进度。 好的,我先汇报我这边的进展。",
|
||||
"segments": [
|
||||
{"start": 0.48, "end": 2.12, "text": "大家好,今天我们讨论项目进度。", "speaker": "S01"},
|
||||
{"start": 2.55, "end": 5.80, "text": "好的,我先汇报我这边的进展。", "speaker": "S02"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**说话人标签说明:** `S01`、`S02` 等由模型自动分配,是匿名编号。
|
||||
同一说话人在同一段音频中标签一致;不同音频之间标签无关联。
|
||||
|
||||
---
|
||||
|
||||
## 接入 sentence_api
|
||||
|
||||
在 `sentence_api/.env` 中确认以下配置:
|
||||
|
||||
```dotenv
|
||||
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
||||
MOSS_MODEL=OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
```
|
||||
|
||||
然后重建 API 容器:
|
||||
|
||||
```bash
|
||||
cd /opt/oral-trainer/sentence_api
|
||||
docker compose up -d --build
|
||||
curl http://127.0.0.1:8000/healthz # 确认 moss_configured 为 true
|
||||
```
|
||||
|
||||
已上传的视频可在管理后台点击"重新处理",重新转写后会带上说话人标签。
|
||||
|
||||
---
|
||||
|
||||
## 从 Whisper 切换到 MOSS
|
||||
|
||||
如果服务器上同时部署了 Whisper(Speaches)和 MOSS,切换步骤:
|
||||
|
||||
1. **停止 Whisper 服务**(释放约 4 GB 显存):
|
||||
|
||||
```bash
|
||||
cd /opt/whisper
|
||||
docker compose down
|
||||
sudo systemctl disable --now whisper-transcribe # 如果注册了 systemd
|
||||
```
|
||||
|
||||
2. **修改 `sentence_api/.env`**:
|
||||
|
||||
```dotenv
|
||||
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
||||
MOSS_MODEL=OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
```
|
||||
|
||||
3. **重建 API**:
|
||||
|
||||
```bash
|
||||
cd /opt/oral-trainer/sentence_api
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
4. **验证**:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
```
|
||||
|
||||
不需要删除 Whisper 的部署文件,后续切回只需恢复 `.env` 并重新启动。
|
||||
|
||||
---
|
||||
|
||||
## 显存分配参考
|
||||
|
||||
以 NVIDIA L4(24 GB)为例,MOSS 与其他服务共存的显存分配:
|
||||
|
||||
| 服务 | 显存占用 | 说明 |
|
||||
|------|----------|------|
|
||||
| llama-server | 8.4 GB | 保留 |
|
||||
| MOSS (SGLang, mem-fraction=0.35) | ~8 GB | 新增 |
|
||||
| Speaches (Whisper) | 4 GB | 停掉后释放 |
|
||||
| **MOSS + llama-server** | ~16 GB / 24 GB | 安全 |
|
||||
|
||||
独占 GPU 时可将 `--mem-fraction-static` 调到 `0.80`(约 19 GB),支持更多并发。
|
||||
|
||||
---
|
||||
|
||||
## API 参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `file` | file | 必填 | 上传的音频/视频文件 |
|
||||
| `model` | string | 服务端默认 | 模型标识符 |
|
||||
| `language` | string | 未设置 | 可选的语言提示(如 `zh`、`en`) |
|
||||
| `response_format` | string | `json` | `json`(原始文本)、`verbose_json`(分段)或 `text` |
|
||||
| `temperature` | float | `0.0` | 采样温度,转写建议保持 `0` |
|
||||
| `max_new_tokens` | int | `5120` | 最大生成 token 数;长音频/多人对话调大到 `65536` |
|
||||
| `prompt` | string | 未设置 | 可选的自定义指令或热词提示 |
|
||||
|
||||
### 热词提示
|
||||
|
||||
通过 `prompt` 参数为专有名词、人名、产品名等提供提示,可提高识别准确率:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
|
||||
-F model=OpenMOSS-Team/MOSS-Transcribe-Diarize \
|
||||
-F file=@meeting.wav \
|
||||
-F response_format=verbose_json \
|
||||
-F prompt="热词提示:MOSS, Transcribe, Diarize, vLLM" \
|
||||
-F temperature=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 模型下载慢或失败
|
||||
|
||||
```bash
|
||||
# 方法一:使用 HF 镜像
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
hf download OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
|
||||
# 方法二:配置 HTTP 代理
|
||||
export HTTP_PROXY=http://your-proxy:port
|
||||
export HTTPS_PROXY=http://your-proxy:port
|
||||
```
|
||||
|
||||
### 启动报 CUDA out of memory
|
||||
|
||||
与其他 GPU 服务共存时,降低 `--mem-fraction-static`(如 `0.30`);
|
||||
或停掉暂时不需要的服务(如 Whisper)释放显存。
|
||||
|
||||
### 启动报 model not found
|
||||
|
||||
vLLM 版本过低,不包含 MOSS 模型注册。使用官方指定的 pinned nightly
|
||||
(见方案 B 安装部分),或升级到 vLLM ≥ 0.27.1。
|
||||
|
||||
### 长音频转写截断
|
||||
|
||||
请求时添加 `-F max_new_tokens=65536`,确保解码器能完整生成整段转写。
|
||||
|
||||
### 说话人标签偶尔混淆
|
||||
|
||||
多人重叠说话(同时说话)时 0.9B 模型可能偶尔混淆,这是模型本身的局限。
|
||||
MOSS 采用端到端 joint diarization,比 Whisper + pyannote 拼接方案更稳定,
|
||||
但如果场景对说话人准确率要求极高,可以考虑使用在线的 MOSS Pro 版本。
|
||||
|
||||
### 服务只监听 127.0.0.1,外部怎么访问
|
||||
|
||||
这是有意设计——转写服务不应暴露公网。`sentence_api` 通过 Docker 内部
|
||||
网络或宿主机 `127.0.0.1` 访问即可。如果 `sentence_api` 运行在 Docker
|
||||
容器里且无法访问宿主机的 `127.0.0.1:8001`,将 `MOSS_TRANSCRIBE_URL` 改为
|
||||
`http://host.docker.internal:8001/v1/audio/transcriptions`(Linux 需在
|
||||
`docker-compose.yml` 中添加 `extra_hosts: ["host.docker.internal:host-gateway"]`)。
|
||||
|
||||
---
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
### 更新模型
|
||||
|
||||
```bash
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
hf download OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
sudo systemctl restart moss-transcribe
|
||||
```
|
||||
|
||||
### 更新推理框架
|
||||
|
||||
```bash
|
||||
cd /opt/moss-transcribe/MOSS-Transcribe-Diarize
|
||||
source .venv/bin/activate
|
||||
uv pip install -U sglang-omni # 或 vllm
|
||||
sudo systemctl restart moss-transcribe
|
||||
```
|
||||
|
||||
### 回滚
|
||||
|
||||
```bash
|
||||
# 回滚到上一个 systemd 配置
|
||||
sudo systemctl revert moss-transcribe
|
||||
|
||||
# 或手动恢复备份的服务文件
|
||||
sudo cp /path/to/backup/moss-transcribe.service.bak /etc/systemd/system/moss-transcribe.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart moss-transcribe
|
||||
```
|
||||
16
moss/systemd/moss-transcribe-vllm.service
Normal file
16
moss/systemd/moss-transcribe-vllm.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=MOSS Transcribe Diarize Service (vLLM)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lsm
|
||||
WorkingDirectory=/opt/moss-transcribe
|
||||
Environment=HF_ENDPOINT=https://hf-mirror.com
|
||||
ExecStart=/opt/moss-transcribe/.venv/bin/vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize --host 127.0.0.1 --port 8001 --trust-remote-code
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStartSec=1800
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
20
moss/systemd/moss-transcribe.service
Normal file
20
moss/systemd/moss-transcribe.service
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=MOSS Transcribe Diarize Service (SGLang Omni)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lsm
|
||||
WorkingDirectory=/opt/moss-transcribe/MOSS-Transcribe-Diarize
|
||||
Environment=HF_ENDPOINT=https://hf-mirror.com
|
||||
ExecStart=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin/sgl-omni serve \
|
||||
--model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \
|
||||
--host 127.0.0.1 \
|
||||
--port 8001 \
|
||||
--mem-fraction-static 0.35
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStartSec=1800
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
130
moss/verify.sh
Executable file
130
moss/verify.sh
Executable file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 验证 MOSS-Transcribe-Diarize 转写服务:
|
||||
# 1. 检查 GPU 与服务运行状态
|
||||
# 2. 用测试音频发起 verbose_json 转写请求,校验响应契约和说话人标签
|
||||
# 3. (可选)检查 oral-trainer-api 的集成状态
|
||||
#
|
||||
# 用法:
|
||||
# ./verify.sh
|
||||
# ./verify.sh --file /path/to/speech.wav
|
||||
# ./verify.sh --url http://127.0.0.1:8001 --model OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
# ./verify.sh --api-url http://127.0.0.1:8000
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MOSS_URL="${MOSS_URL:-http://127.0.0.1:8001}"
|
||||
MOSS_MODEL="${MOSS_MODEL:-OpenMOSS-Team/MOSS-Transcribe-Diarize}"
|
||||
TEST_FILE=""
|
||||
API_URL=""
|
||||
|
||||
usage() {
|
||||
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--url) MOSS_URL="$2"; shift 2 ;;
|
||||
--model) MOSS_MODEL="$2"; shift 2 ;;
|
||||
--file) TEST_FILE="$2"; shift 2 ;;
|
||||
--api-url) API_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "未知参数: $1"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> MOSS 地址: $MOSS_URL"
|
||||
echo "==> 模型: $MOSS_MODEL"
|
||||
|
||||
echo "==> 检查 GPU"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
nvidia-smi --query-gpu=name,memory.total,memory.used,driver_version --format=csv,noheader | head -5
|
||||
else
|
||||
echo "警告: 未找到 nvidia-smi,无法确认 GPU 状态。"
|
||||
fi
|
||||
|
||||
echo "==> 检查服务健康"
|
||||
if curl -fsS --max-time 10 "$MOSS_URL/health" > /dev/null 2>&1; then
|
||||
echo "OK: /health 返回 200"
|
||||
elif curl -fsS --max-time 10 "$MOSS_URL/healthz" > /dev/null 2>&1; then
|
||||
echo "OK: /healthz 返回 200"
|
||||
else
|
||||
echo "警告: /health 和 /healthz 均不可达,服务可能尚未就绪。"
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
if [[ -n "$TEST_FILE" ]]; then
|
||||
AUDIO_FILE="$TEST_FILE"
|
||||
else
|
||||
echo "==> 生成测试音频(3 秒 440Hz 正弦波)"
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "错误: 未找到 ffmpeg,请安装或用 --file 指定真实语音文件。" >&2
|
||||
exit 1
|
||||
fi
|
||||
AUDIO_FILE="$TMP_DIR/tone.wav"
|
||||
ffmpeg -hide_banner -loglevel error -f lavfi \
|
||||
-i "sine=frequency=440:duration=3" -ar 16000 -ac 1 -y "$AUDIO_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$AUDIO_FILE" ]]; then
|
||||
echo "错误: 音频文件不存在: $AUDIO_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> 发起转写请求(verbose_json)"
|
||||
START_TS="$(date +%s)"
|
||||
RESPONSE="$(curl -sS --max-time 600 \
|
||||
-X POST "$MOSS_URL/v1/audio/transcriptions" \
|
||||
-F "model=$MOSS_MODEL" \
|
||||
-F "file=@$AUDIO_FILE" \
|
||||
-F "response_format=verbose_json" \
|
||||
-F "temperature=0" \
|
||||
-F "max_new_tokens=65536")"
|
||||
ELAPSED="$(( $(date +%s) - START_TS ))"
|
||||
|
||||
echo "$RESPONSE" | python3 -c '
|
||||
import json, sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
text = payload.get("text")
|
||||
segments = payload.get("segments")
|
||||
|
||||
assert isinstance(text, str), "响应缺少 text 字段"
|
||||
assert isinstance(segments, list), "响应缺少 segments 字段(需要 verbose_json)"
|
||||
|
||||
speakers = set()
|
||||
for index, segment in enumerate(segments):
|
||||
start = segment.get("start")
|
||||
end = segment.get("end")
|
||||
speaker = segment.get("speaker")
|
||||
if not (isinstance(start, (int, float)) and isinstance(end, (int, float)) and end > start >= 0):
|
||||
raise AssertionError(f"segments[{index}] 缺少合法 start/end")
|
||||
if speaker:
|
||||
speakers.add(speaker)
|
||||
|
||||
print(f"OK: text={text!r}")
|
||||
print(f"OK: segments={len(segments)} 条")
|
||||
if speakers:
|
||||
print(f"OK: 说话人={sorted(speakers)}")
|
||||
else:
|
||||
print("注意: 未见 speaker 字段;单说话人或模型未输出标签(正常弦音无多人对话)。")
|
||||
'
|
||||
echo "==> 转写耗时: ${ELAPSED}s"
|
||||
|
||||
if [[ -n "$API_URL" ]]; then
|
||||
echo "==> 检查 API 集成 ($API_URL/healthz)"
|
||||
curl -sS --max-time 15 "$API_URL/healthz" | python3 -c '
|
||||
import json, sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
configured = payload.get("moss_configured")
|
||||
if configured is not True:
|
||||
raise SystemExit(f"错误: moss_configured={configured!r},请检查 .env 中的 MOSS_TRANSCRIBE_URL")
|
||||
print("OK: moss_configured=true")
|
||||
'
|
||||
fi
|
||||
|
||||
echo "全部检查通过。建议再用真实多人对话音频复核说话人分离效果:"
|
||||
echo " $0 --file /path/to/multi-speaker.wav"
|
||||
@@ -1,4 +1,5 @@
|
||||
av>=12.0
|
||||
requests>=2.32.0
|
||||
sounddevice>=0.4.6
|
||||
numpy>=1.26
|
||||
Pillow>=10.0
|
||||
|
||||
60
scripts/register_oral_trainer_oauth_client.js
Normal file
60
scripts/register_oral_trainer_oauth_client.js
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 在第一课堂 MongoDB 中注册口语训练 App 的 OAuth2 客户端。
|
||||
* 在第一课堂服务器上运行:
|
||||
* cd /path/to/typing_practiceweb
|
||||
* MONGODB_URI=<uri> node scripts/register_oral_trainer_oauth_client.js
|
||||
*/
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const CLIENT_ID = 'oral-trainer-app';
|
||||
const CLIENT_SECRET = 'oral-trainer-secret-2024';
|
||||
const CLIENT_NAME = '口语训练 App';
|
||||
|
||||
async function main() {
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) {
|
||||
console.error('MONGODB_URI is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await mongoose.connect(uri);
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
const collection = db.collection('oauth2clients');
|
||||
|
||||
const existing = await collection.findOne({ clientId: CLIENT_ID });
|
||||
if (existing) {
|
||||
console.log(`Client "${CLIENT_ID}" already exists. Updating redirect URIs...`);
|
||||
await collection.updateOne(
|
||||
{ clientId: CLIENT_ID },
|
||||
{ $set: { clientSecret: CLIENT_SECRET, name: CLIENT_NAME } }
|
||||
);
|
||||
} else {
|
||||
console.log(`Creating OAuth2 client "${CLIENT_ID}"...`);
|
||||
await collection.insertOne({
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
name: CLIENT_NAME,
|
||||
redirectUris: [
|
||||
'oraltrainer://oauth/callback',
|
||||
],
|
||||
grants: ['authorization_code'],
|
||||
scope: ['openid', 'profile', 'email', 'firstname', 'lastname', 'username'],
|
||||
createdAt: new Date(),
|
||||
linkedUsers: [],
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Done. Client config:');
|
||||
console.log(` client_id: ${CLIENT_ID}`);
|
||||
console.log(` client_secret: ${CLIENT_SECRET}`);
|
||||
console.log(` redirect_uri: oraltrainer://oauth/callback`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ MAX_VIDEO_UPLOAD_BYTES=12884901888
|
||||
MAX_ATTEMPT_UPLOAD_BYTES=52428800
|
||||
KEEP_ATTEMPT_AUDIO=false
|
||||
ASSESSMENT_PASS_SCORE=70
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
||||
MOSS_MODEL=OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||
@@ -16,3 +17,23 @@ MOSS_TIMEOUT_SECONDS=1800
|
||||
MOSS_MAX_NEW_TOKENS=65536
|
||||
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
|
||||
MOSS_END_PADDING_MS=300
|
||||
|
||||
# AI sentence explanation (OpenAI-compatible chat endpoint). A base URL ending
|
||||
# in /v1 is also accepted and will automatically use /v1/chat/completions.
|
||||
# The URL and key stay on the server; mobile clients only send the sentence text.
|
||||
AI_API_URL=https://api.example.com/v1/chat/completions
|
||||
AI_API_KEY=replace-with-your-ai-api-key
|
||||
AI_MODEL=gpt-4o-mini
|
||||
AI_TIMEOUT_SECONDS=45
|
||||
# 后台批量翻译可使用更长时间;实际翻译超时 = max(AI_TIMEOUT_SECONDS, AI_TRANSLATION_TIMEOUT_SECONDS)
|
||||
AI_TRANSLATION_TIMEOUT_SECONDS=120
|
||||
|
||||
# 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。
|
||||
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key
|
||||
VOLCANO_OSS_SECRET_KEY=replace-with-volcano-secret-key
|
||||
VOLCANO_OSS_BUCKET=your-course-videos
|
||||
VOLCANO_OSS_ENDPOINT=tos-cn-beijing.volces.com
|
||||
VOLCANO_OSS_REGION=cn-beijing
|
||||
VOLCANO_OSS_KEY_PREFIX=videos/
|
||||
# 可选:绑定自定义域名或 CDN 后填写,例如 https://media.example.com
|
||||
# VOLCANO_OSS_PUBLIC_BASE_URL=https://media.example.com
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
- `oral-trainer-api`:视频上传、管理后台、边界查询、Range 视频播放和朗读评分。
|
||||
- GPU 转写服务(二选一,只在服务器内网监听):
|
||||
- `whisper/`:faster-whisper(Speaches),推荐,部署最简单,见第 2A 节。
|
||||
- `MOSS-Transcribe-Diarize`:带说话人分离,见第 2 节。
|
||||
- `MOSS-Transcribe-Diarize`:带说话人分离,见第 2 节和
|
||||
[`moss/README.md`](../moss/README.md)。
|
||||
|
||||
推荐 Ubuntu 22.04/24.04、Python 3.12、FFmpeg、NVIDIA GPU 和已安装的 NVIDIA 驱动。MOSS 0.9B 的实际显存占用受推理框架、并发和音频长度影响,生产环境建议从 16 GB 以上显存开始验证。
|
||||
|
||||
@@ -50,6 +51,8 @@ cp -a whisper/. /opt/whisper/ # 用 whisper/. 而不是 whisper/*,否则不
|
||||
cd /opt/whisper
|
||||
|
||||
cp .env.example .env # 按需修改 WHISPER_MODEL
|
||||
# 默认从 DaoCloud 拉 ghcr 镜像;能直连 GitHub 时在 .env 里设
|
||||
# WHISPER_IMAGE=ghcr.io/speaches-ai/speaches:latest-cuda
|
||||
docker compose up -d
|
||||
docker compose logs -f whisper # 首次启动下载模型(约 3 GB)
|
||||
```
|
||||
@@ -153,8 +156,13 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
|
||||
### 断句规则
|
||||
|
||||
`sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积,
|
||||
只有碰到句号(`.`)或问号(`?`)才算一句结束;Whisper 在没有标点处断开的
|
||||
segment 会自动合并成同一句。若转写服务支持词级时间戳
|
||||
只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束;
|
||||
逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有
|
||||
标点处断开的 segment 会自动合并成同一句。管理后台的文本保存、换行拆分占位、相邻句
|
||||
合并和结束时间微调会立即入库,但不会触发翻译或转写;关闭句子对话框时才统一执行
|
||||
一轮拆分时间戳处理和翻译。拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按
|
||||
文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新,
|
||||
并保证相邻句子不重叠。若转写服务支持词级时间戳
|
||||
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
|
||||
标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为
|
||||
不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。
|
||||
@@ -181,12 +189,42 @@ openssl rand -hex 32
|
||||
```dotenv
|
||||
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
|
||||
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
||||
AI_API_URL=https://api.example.com/v1/chat/completions
|
||||
AI_API_KEY=replace-with-your-ai-api-key
|
||||
AI_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
若使用 Whisper 方案(第 2A 节),把 `MOSS_TRANSCRIBE_URL` 改为
|
||||
`http://127.0.0.1:9000/v1/audio/transcriptions`,并把 `MOSS_MODEL` 设为
|
||||
`Systran/faster-whisper-large-v3`。
|
||||
|
||||
Dockerfile 默认使用国内镜像源,避免构建时从 Docker Hub / Debian / PyPI 直连过慢:
|
||||
|
||||
- 基础镜像:`docker.m.daocloud.io/library/python:3.12-slim`
|
||||
- apt:`mirrors.aliyun.com`
|
||||
- pip:清华 `https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
|
||||
如需覆盖,可在 `sentence_api/.env` 或构建命令前设置:
|
||||
|
||||
```bash
|
||||
export PYTHON_IMAGE=python:3.12-slim # 能直连 Docker Hub 时
|
||||
export PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple
|
||||
export PIP_TRUSTED_HOST=mirrors.aliyun.com
|
||||
export APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
```
|
||||
|
||||
拉 Docker Hub 官方镜像仍然慢时,把仓库里的 `sentence_api/docker-daemon.json.example`
|
||||
拷到 Docker 配置并重启:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/docker
|
||||
sudo cp sentence_api/docker-daemon.json.example /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
`registry-mirrors` 只加速 `docker.io`。Whisper 的 `ghcr.io` 镜像请用 compose 里默认的
|
||||
DaoCloud 前缀(见第 2A 节)。
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
FROM python:3.12-slim
|
||||
ARG PYTHON_IMAGE=docker.m.daocloud.io/library/python:3.12-slim
|
||||
FROM ${PYTHON_IMAGE}
|
||||
|
||||
ARG APT_MIRROR=mirrors.aliyun.com
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_INDEX_URL=${PIP_INDEX_URL} \
|
||||
PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
|
||||
|
||||
RUN apt-get update \
|
||||
RUN set -eux; \
|
||||
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
|
||||
sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list.d/debian.sources; \
|
||||
fi; \
|
||||
if [ -f /etc/apt/sources.list ]; then \
|
||||
sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list; \
|
||||
fi; \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -4,11 +4,16 @@ FastAPI service for the Android oral-training SDK. It provides:
|
||||
|
||||
- chunked video upload with SHA-256 calculation;
|
||||
- files stored under `data/v/` and metadata stored in SQLite;
|
||||
- after the first processing pass, videos can be uploaded automatically to
|
||||
Volcano Engine Object Storage (TOS / 火山 OSS) and the catalog/share UI will
|
||||
use that public URL;
|
||||
- background sentence transcription through MOSS-Transcribe-Diarize;
|
||||
- a browser-based administration page at `/admin`;
|
||||
- video catalog and HTTP Range playback;
|
||||
- sentence-boundary lookup compatible with the existing Android SDK;
|
||||
- student recording upload and `asr-fluency-v1` assessment;
|
||||
- signed-in user history for test results and dubbing shares, including deletion;
|
||||
- AI sentence explanation for signed-in mobile users;
|
||||
- the legacy JSON boundary index as a read-only fallback.
|
||||
|
||||
## Local Run
|
||||
@@ -33,6 +38,12 @@ http://127.0.0.1:8000/docs
|
||||
When `MOSS_TRANSCRIBE_URL` is empty, uploaded videos still receive silence-based
|
||||
boundaries, but no reference transcript is produced and assessment is disabled.
|
||||
|
||||
When all `VOLCANO_OSS_*` credentials in `.env.example` are configured, the first
|
||||
processing pass saves sentence boundaries, uploads the source video, and only
|
||||
then marks it ready. `stream_url` and the legacy `/content` endpoint then point
|
||||
to the OSS/CDN URL. If the upload fails, the video is marked failed so clients
|
||||
do not fall back to server-local playback.
|
||||
|
||||
## Main Endpoints
|
||||
|
||||
```text
|
||||
@@ -41,18 +52,74 @@ GET /api/v1/videos
|
||||
GET /api/v1/videos/{sha256}
|
||||
GET /api/v1/videos/{sha256}/content
|
||||
GET /api/v1/videos/{sha256}/sentence-boundaries
|
||||
POST /api/v1/audio/sentence-boundaries
|
||||
POST /api/v1/sentences/explain
|
||||
POST /api/v1/videos/{sha256}/sentences/{index}/assessments
|
||||
|
||||
POST /api/v1/admin/videos
|
||||
PUT /api/v1/admin/videos/raw
|
||||
POST /api/v1/admin/videos/{sha256}/process
|
||||
POST /api/v1/admin/videos/{sha256}/repair-gaps
|
||||
GET /api/v1/admin/videos/{sha256}/subtitle-translation
|
||||
POST /api/v1/admin/videos/{sha256}/subtitle-translation
|
||||
POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair
|
||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
|
||||
PUT /api/v1/admin/videos/{sha256}/sentences/batch
|
||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
|
||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
|
||||
PUT /api/v1/admin/users/password
|
||||
DELETE /api/v1/admin/videos/{sha256}
|
||||
```
|
||||
|
||||
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
|
||||
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
|
||||
|
||||
Administrators can set a local password for an SSO-linked account with:
|
||||
|
||||
```bash
|
||||
curl -X PUT https://your-server/api/v1/admin/users/password \
|
||||
-H "X-Admin-Key: $ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"bobcoc","password":"new-password"}'
|
||||
```
|
||||
|
||||
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
|
||||
authenticated client, generates boundaries in memory/temp storage only, and
|
||||
returns a sentence document. It does not register or archive the uploaded
|
||||
audio.
|
||||
|
||||
## AI Sentence Explanation
|
||||
|
||||
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.
|
||||
A base URL ending in `/v1` is also accepted and is resolved to
|
||||
`/v1/chat/completions`:
|
||||
|
||||
```dotenv
|
||||
AI_API_URL=https://api.example.com/v1/chat/completions
|
||||
AI_API_KEY=replace-with-your-ai-api-key
|
||||
AI_MODEL=gpt-4o-mini
|
||||
AI_TIMEOUT_SECONDS=45
|
||||
```
|
||||
|
||||
The endpoint requires a signed-in user and accepts only the sentence and course
|
||||
context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
|
||||
to the mobile clients.
|
||||
|
||||
Chinese captions are stored on the course in the server database and shared by
|
||||
every user. `GET /api/v1/videos/{sha256}/subtitle-translation` returns the saved
|
||||
captions without calling the AI, including `complete` and `status` so clients can
|
||||
poll long videos. `POST` starts missing-caption generation in the background and
|
||||
returns quickly; it translates in small windows with neighboring context, runs
|
||||
several windows in parallel, retries and splits a window if the model response is
|
||||
invalid, and writes each finished window immediately so later viewers can reuse
|
||||
it. English sentence edits clear the saved Chinese captions for that course.
|
||||
|
||||
Desktop playback of a local file can `POST` the same endpoint with the locally
|
||||
generated English cues (`duration_ms`, `algorithm_version`, `sentences`). If that
|
||||
SHA-256 is not already a course, the server stores the English subtitles and
|
||||
translates them; later `GET`/`POST` calls poll and reuse the saved Chinese
|
||||
captions. Official course sentences are never overwritten by a local upload.
|
||||
|
||||
## Assessment
|
||||
|
||||
```bash
|
||||
|
||||
@@ -180,8 +180,8 @@ sudo systemctl restart whisper-transcribe
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 更新后接口行为没变 | 忘了 `--build`,容器还在跑旧镜像 | 执行 `docker compose up -d --build` |
|
||||
| 构建时 pip 安装超时 | 服务器访问 PyPI 受限 | 给 Dockerfile 的 pip install 加 `-i https://pypi.tuna.tsinghua.edu.cn/simple` 后重新构建 |
|
||||
| 构建时拉基础镜像超时 | Docker Hub 网络问题 | 检查 `/etc/docker/daemon.json` 的 `registry-mirrors`(已配置 DaoCloud) |
|
||||
| 构建时 pip 安装超时 | 服务器访问 PyPI 受限 | Dockerfile 默认已用清华 PyPI;仍慢可改 `PIP_INDEX_URL` 后 `--build` |
|
||||
| 构建时拉基础镜像超时 | Docker Hub 网络问题 | Dockerfile 默认从 DaoCloud 拉 `python:3.12-slim`;也可把 `docker-daemon.json.example` 拷到 `/etc/docker/daemon.json` 后 `systemctl restart docker` |
|
||||
| `healthz` 里 `moss_configured: false` | `.env` 里 `MOSS_TRANSCRIBE_URL` 为空或改了未重建 | 改 `.env` 后执行 `docker compose up -d` |
|
||||
| Whisper 9000 端口连不上 | 服务未运行或容器被删 | `systemctl status whisper-transcribe`;容器被删时用 `systemctl start` 重建 |
|
||||
| API 重启后视频卡在 `processing` | 重启中断了处理流程 | 后台点“重新处理”即可 |
|
||||
|
||||
655
sentence_api/ai_tutor.py
Normal file
655
sentence_api/ai_tutor.py
Normal file
@@ -0,0 +1,655 @@
|
||||
"""AI-backed sentence explanations and subtitle translation."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
|
||||
_CJK_RE = re.compile("[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
||||
_NUMBERED_LINE_RE = re.compile(r"^\s*[\[(]?(\d+)[\])]?[\.、::\)]\s*(.+?)\s*$")
|
||||
_TRANSLATE_BATCH_SENTENCES = 12
|
||||
_TRANSLATE_BATCH_CHARS = 2_200
|
||||
_TRANSLATE_CONTEXT_BEFORE = 8
|
||||
_TRANSLATE_CONTEXT_AFTER = 4
|
||||
_TRANSLATE_WORKERS = 4
|
||||
_TRANSLATE_TIMEOUT_SECONDS = 120.0
|
||||
_TRANSLATE_SPLIT_THRESHOLD = 4
|
||||
|
||||
|
||||
class AITutorError(RuntimeError):
|
||||
"""Raised when the configured AI service cannot answer a request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AITutor:
|
||||
endpoint: str
|
||||
api_key: str
|
||||
model: str
|
||||
timeout_seconds: float
|
||||
translation_timeout_seconds: float = _TRANSLATE_TIMEOUT_SECONDS
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return bool(self.endpoint and self.model)
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
"""Resolve either a full chat URL or an OpenAI-compatible base URL."""
|
||||
parsed = urlsplit(self.endpoint)
|
||||
path = parsed.path.rstrip("/")
|
||||
if parsed.query or path.endswith("/chat/completions"):
|
||||
return self.endpoint
|
||||
if path.endswith("/v1") or path.endswith("/v1/openai"):
|
||||
return self.endpoint + "/chat/completions"
|
||||
if not path:
|
||||
return self.endpoint + "/v1/chat/completions"
|
||||
return self.endpoint
|
||||
|
||||
def explain(
|
||||
self,
|
||||
sentence_text: str,
|
||||
*,
|
||||
course_title: str = "",
|
||||
sentence_index: int | None = None,
|
||||
language: str = "",
|
||||
) -> str:
|
||||
if not self.available:
|
||||
raise AITutorError("AI explanation is not configured.")
|
||||
|
||||
context = course_title.strip() or "口语训练课程"
|
||||
position = f"第 {sentence_index + 1} 句" if sentence_index is not None else "当前句子"
|
||||
language_note = f"句子语言:{language.strip()}。" if language.strip() else ""
|
||||
system_prompt = (
|
||||
"你是一位耐心的外语口语老师。请用简体中文讲解用户提供的影视/课程句子。"
|
||||
"先给出自然的中文翻译,再解释关键词、常用搭配、语法要点,并简要说明这句话适合怎样跟读。"
|
||||
"回答使用简明清晰的分段短文,总长度不超过 600 字,不要编造视频里没有的信息。"
|
||||
)
|
||||
user_prompt = (
|
||||
f"课程:{context}\n位置:{position}\n{language_note}句子:{sentence_text}"
|
||||
)
|
||||
return self._complete(system_prompt, user_prompt, temperature=0.3)
|
||||
|
||||
def translate_sentences(
|
||||
self,
|
||||
sentences: list[tuple[int, str]],
|
||||
*,
|
||||
course_title: str = "",
|
||||
existing: dict[int, str] | None = None,
|
||||
persist=None,
|
||||
terms: list[tuple[str, str]] | None = None,
|
||||
) -> dict[int, str]:
|
||||
if not self.available:
|
||||
raise AITutorError("AI translation is not configured.")
|
||||
if not sentences:
|
||||
raise AITutorError("No sentences were provided for translation.")
|
||||
|
||||
ordered = [(index, text.strip()) for index, text in sentences if text.strip()]
|
||||
if not ordered:
|
||||
raise AITutorError("No sentence text was provided for translation.")
|
||||
|
||||
glossary = _normalize_translation_terms(terms)
|
||||
source_by_index = {index: text for index, text in ordered}
|
||||
translations = {
|
||||
index: text.strip()
|
||||
for index, text in (existing or {}).items()
|
||||
if isinstance(text, str)
|
||||
and text.strip()
|
||||
and not translation_needs_repair(source_by_index.get(index, ""), text)
|
||||
}
|
||||
pending = [(index, text) for index, text in ordered if index not in translations]
|
||||
if not pending:
|
||||
return translations
|
||||
|
||||
persist_lock = threading.Lock()
|
||||
|
||||
def save_partial(partial: dict[int, str]) -> None:
|
||||
if not partial:
|
||||
return
|
||||
translations.update(partial)
|
||||
if persist is None:
|
||||
return
|
||||
with persist_lock:
|
||||
persist(partial)
|
||||
|
||||
batches = _translation_batches(pending, ordered)
|
||||
workers = min(_TRANSLATE_WORKERS, len(batches)) or 1
|
||||
failures: list[tuple[list[tuple[int, str]], Exception]] = []
|
||||
|
||||
def run_batch(batch: list[tuple[int, str]]) -> dict[int, str]:
|
||||
return self._translate_batch_resilient(
|
||||
batch,
|
||||
all_sentences=ordered,
|
||||
existing=translations,
|
||||
course_title=course_title,
|
||||
terms=glossary,
|
||||
)
|
||||
|
||||
if workers == 1:
|
||||
for batch in batches:
|
||||
try:
|
||||
save_partial(run_batch(batch))
|
||||
except Exception as exc: # noqa: BLE001 - collect and retry below
|
||||
failures.append((batch, exc))
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = {pool.submit(run_batch, batch): batch for batch in batches}
|
||||
for future in as_completed(futures):
|
||||
batch = futures[future]
|
||||
try:
|
||||
save_partial(future.result())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures.append((batch, exc))
|
||||
|
||||
for batch, error in failures:
|
||||
logger.warning(
|
||||
"Translation batch failed, retrying with smaller windows: size=%s error=%s",
|
||||
len(batch),
|
||||
error,
|
||||
)
|
||||
save_partial(
|
||||
self._translate_batch_resilient(
|
||||
batch,
|
||||
all_sentences=ordered,
|
||||
existing=translations,
|
||||
course_title=course_title,
|
||||
terms=glossary,
|
||||
force_split=len(batch) > 1,
|
||||
)
|
||||
)
|
||||
|
||||
missing = [index for index, _ in pending if not translations.get(index, "").strip()]
|
||||
if missing:
|
||||
raise AITutorError("AI translation did not cover every sentence.")
|
||||
if glossary:
|
||||
source_by_index = {index: text for index, text in ordered}
|
||||
translations = {
|
||||
index: _apply_translation_terms(source_by_index.get(index, ""), zh_text, glossary)
|
||||
for index, zh_text in translations.items()
|
||||
}
|
||||
return translations
|
||||
|
||||
def _translate_batch_resilient(
|
||||
self,
|
||||
batch: list[tuple[int, str]],
|
||||
*,
|
||||
all_sentences: list[tuple[int, str]],
|
||||
existing: dict[int, str],
|
||||
course_title: str,
|
||||
terms: list[tuple[str, str]] | None = None,
|
||||
force_split: bool = False,
|
||||
) -> dict[int, str]:
|
||||
if not batch:
|
||||
return {}
|
||||
if force_split and len(batch) > 1:
|
||||
return self._translate_split_batch(
|
||||
batch,
|
||||
all_sentences=all_sentences,
|
||||
existing=existing,
|
||||
course_title=course_title,
|
||||
terms=terms,
|
||||
)
|
||||
|
||||
last_error: Exception | None = None
|
||||
attempts = 2 if len(batch) <= _TRANSLATE_SPLIT_THRESHOLD else 1
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
return self._translate_batch(
|
||||
batch,
|
||||
all_sentences=all_sentences,
|
||||
existing=existing,
|
||||
course_title=course_title,
|
||||
terms=terms,
|
||||
)
|
||||
except AITutorError as exc:
|
||||
last_error = exc
|
||||
logger.warning(
|
||||
"Translation batch attempt failed: size=%s indexes=%s error=%s",
|
||||
len(batch),
|
||||
[index for index, _ in batch],
|
||||
exc,
|
||||
)
|
||||
|
||||
if len(batch) > 1:
|
||||
return self._translate_split_batch(
|
||||
batch,
|
||||
all_sentences=all_sentences,
|
||||
existing=existing,
|
||||
course_title=course_title,
|
||||
terms=terms,
|
||||
)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise AITutorError("AI translation did not cover every sentence.")
|
||||
|
||||
def _translate_split_batch(
|
||||
self,
|
||||
batch: list[tuple[int, str]],
|
||||
*,
|
||||
all_sentences: list[tuple[int, str]],
|
||||
existing: dict[int, str],
|
||||
course_title: str,
|
||||
terms: list[tuple[str, str]] | None = None,
|
||||
) -> dict[int, str]:
|
||||
mid = max(1, len(batch) // 2)
|
||||
merged: dict[int, str] = {}
|
||||
for part in (batch[:mid], batch[mid:]):
|
||||
merged.update(
|
||||
self._translate_batch_resilient(
|
||||
part,
|
||||
all_sentences=all_sentences,
|
||||
existing=existing,
|
||||
course_title=course_title,
|
||||
terms=terms,
|
||||
)
|
||||
)
|
||||
return merged
|
||||
|
||||
def _translate_batch(
|
||||
self,
|
||||
batch: list[tuple[int, str]],
|
||||
*,
|
||||
all_sentences: list[tuple[int, str]],
|
||||
existing: dict[int, str],
|
||||
course_title: str,
|
||||
terms: list[tuple[str, str]] | None = None,
|
||||
) -> dict[int, str]:
|
||||
context = course_title.strip() or "口语训练课程"
|
||||
existing = dict(existing)
|
||||
before, after = _neighbor_context(batch, all_sentences)
|
||||
expected_indexes = [index for index, _ in batch]
|
||||
glossary = _normalize_translation_terms(terms)
|
||||
system_prompt = (
|
||||
"你是专业的影视字幕翻译。请结合前后文语境,把指定编号的外语句子翻译成自然、口语化的简体中文。"
|
||||
"必须一句对一句,不能合并、拆分、跳过或改写编号。"
|
||||
"只输出 JSON 对象,键是句子编号,值是对应中文,不要输出解释或 markdown。"
|
||||
)
|
||||
if glossary:
|
||||
system_prompt += "必须严格使用用户指定的专有名词译法,禁止自行音译成其他写法。"
|
||||
user_parts = [
|
||||
f"课程:{context}",
|
||||
f"本次需要翻译 {len(batch)} 句,编号必须与输入完全一致。",
|
||||
]
|
||||
if glossary:
|
||||
user_parts.append("专有名词译法(必须严格遵守,原文出现时译文必须使用指定中文):")
|
||||
user_parts.extend(f"{source} => {zh}" for source, zh in glossary)
|
||||
previous = [
|
||||
(index, source, existing[index])
|
||||
for index, source in before
|
||||
if existing.get(index, "").strip()
|
||||
]
|
||||
if previous:
|
||||
user_parts.append("已有译文(供用词一致,不要重复输出):")
|
||||
user_parts.extend(
|
||||
f"[{index}] {source} => {zh}" for index, source, zh in previous[-6:]
|
||||
)
|
||||
elif before:
|
||||
user_parts.append("前文(只供理解语境,不要翻译):")
|
||||
user_parts.extend(f"[{index}] {text}" for index, text in before)
|
||||
if after:
|
||||
user_parts.append("后文(只供理解语境,不要翻译):")
|
||||
user_parts.extend(f"[{index}] {text}" for index, text in after)
|
||||
user_parts.append("待翻译句子:")
|
||||
user_parts.extend(f"[{index}] {text}" for index, text in batch)
|
||||
user_parts.append(
|
||||
"输出示例:{" + ",".join(f'"{index}":"中文"' for index in expected_indexes[:2]) + "}"
|
||||
)
|
||||
user_prompt = "\n".join(user_parts)
|
||||
content = self._complete(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
temperature=0.2,
|
||||
timeout_seconds=max(self.timeout_seconds, self.translation_timeout_seconds),
|
||||
)
|
||||
try:
|
||||
parsed = _parse_translation_payload(content, expected_indexes)
|
||||
except AITutorError:
|
||||
logger.warning(
|
||||
"Translation response parse failed: endpoint=%s model=%s "
|
||||
"expected_indexes=%s prompt=%r raw_content=%r",
|
||||
self.request_url,
|
||||
self.model,
|
||||
expected_indexes,
|
||||
user_prompt[:2000],
|
||||
content[:2000],
|
||||
)
|
||||
raise
|
||||
source_by_index = {index: text for index, text in batch}
|
||||
missing = [
|
||||
index
|
||||
for index in expected_indexes
|
||||
if translation_needs_repair(source_by_index.get(index, ""), parsed.get(index, ""))
|
||||
]
|
||||
if missing:
|
||||
invalid = [
|
||||
{
|
||||
"index": index,
|
||||
"source": source_by_index.get(index, ""),
|
||||
"translation": parsed.get(index, ""),
|
||||
}
|
||||
for index in missing
|
||||
]
|
||||
logger.warning(
|
||||
"Translation response failed validation: endpoint=%s model=%s "
|
||||
"expected_indexes=%s parsed=%r invalid=%r prompt=%r raw_content=%r",
|
||||
self.request_url,
|
||||
self.model,
|
||||
expected_indexes,
|
||||
parsed,
|
||||
invalid,
|
||||
user_prompt[:2000],
|
||||
content[:2000],
|
||||
)
|
||||
raise AITutorError("AI translation returned an unexpected response.")
|
||||
return {
|
||||
index: _apply_translation_terms(source_by_index.get(index, ""), parsed[index], glossary)
|
||||
for index in expected_indexes
|
||||
}
|
||||
|
||||
def _complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
temperature: float,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> str:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
timeout = self.timeout_seconds if timeout_seconds is None else timeout_seconds
|
||||
try:
|
||||
response = requests.post(
|
||||
self.request_url,
|
||||
headers=headers,
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.Timeout as exc:
|
||||
logger.warning(
|
||||
"AI request timed out: endpoint=%s model=%s timeout_seconds=%s",
|
||||
self.request_url,
|
||||
self.model,
|
||||
timeout,
|
||||
)
|
||||
raise AITutorError(
|
||||
f"AI service request timed out after {timeout:g} seconds."
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"AI request failed: endpoint=%s model=%s",
|
||||
self.request_url,
|
||||
self.model,
|
||||
)
|
||||
raise AITutorError("AI service request failed.") from exc
|
||||
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.warning(
|
||||
"AI service returned HTTP %s: endpoint=%s model=%s body=%s",
|
||||
response.status_code,
|
||||
self.request_url,
|
||||
self.model,
|
||||
response.text[:1000],
|
||||
)
|
||||
raise AITutorError(f"AI service returned HTTP {response.status_code}.")
|
||||
try:
|
||||
body = response.json()
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"AI service returned an unexpected body: endpoint=%s body=%s",
|
||||
self.request_url,
|
||||
response.text[:1000],
|
||||
)
|
||||
raise AITutorError("AI service returned an unexpected response.") from exc
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
logger.warning(
|
||||
"AI service returned empty content: endpoint=%s body=%s",
|
||||
self.request_url,
|
||||
response.text[:1000],
|
||||
)
|
||||
raise AITutorError("AI service returned an empty explanation.")
|
||||
return content.strip()
|
||||
|
||||
|
||||
def translation_needs_repair(source: str | None, zh_text: str | None) -> bool:
|
||||
"""Return True when a sentence still needs a Chinese translation."""
|
||||
source_text = (source or "").strip()
|
||||
if not source_text:
|
||||
return False
|
||||
zh = (zh_text or "").strip()
|
||||
if not zh:
|
||||
return True
|
||||
if _CJK_RE.search(zh):
|
||||
return False
|
||||
return _CJK_RE.search(source_text) is None
|
||||
|
||||
|
||||
def _translation_batches(
|
||||
pending: list[tuple[int, str]],
|
||||
all_sentences: list[tuple[int, str]],
|
||||
) -> list[list[tuple[int, str]]]:
|
||||
position = {index: offset for offset, (index, _) in enumerate(all_sentences)}
|
||||
ordered_pending = sorted(pending, key=lambda item: position[item[0]])
|
||||
runs: list[list[tuple[int, str]]] = []
|
||||
current: list[tuple[int, str]] = []
|
||||
last_pos: int | None = None
|
||||
for item in ordered_pending:
|
||||
pos = position[item[0]]
|
||||
if current and last_pos is not None and pos != last_pos + 1:
|
||||
runs.append(current)
|
||||
current = []
|
||||
current.append(item)
|
||||
last_pos = pos
|
||||
if current:
|
||||
runs.append(current)
|
||||
|
||||
batches: list[list[tuple[int, str]]] = []
|
||||
for run in runs:
|
||||
batch: list[tuple[int, str]] = []
|
||||
batch_chars = 0
|
||||
for item in run:
|
||||
extra = len(item[1]) + 8
|
||||
too_many = len(batch) >= _TRANSLATE_BATCH_SENTENCES
|
||||
too_long = batch and batch_chars + extra > _TRANSLATE_BATCH_CHARS
|
||||
if too_many or too_long:
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
batch_chars = 0
|
||||
batch.append(item)
|
||||
batch_chars += extra
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
|
||||
|
||||
def _neighbor_context(
|
||||
batch: list[tuple[int, str]],
|
||||
all_sentences: list[tuple[int, str]],
|
||||
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
||||
position = {index: offset for offset, (index, _) in enumerate(all_sentences)}
|
||||
first = position[batch[0][0]]
|
||||
last = position[batch[-1][0]]
|
||||
before = all_sentences[max(0, first - _TRANSLATE_CONTEXT_BEFORE):first]
|
||||
after = all_sentences[last + 1:last + 1 + _TRANSLATE_CONTEXT_AFTER]
|
||||
return before, after
|
||||
|
||||
|
||||
def _parse_translation_payload(content: str, expected_indexes: list[int] | int) -> dict[int, str]:
|
||||
if isinstance(expected_indexes, int):
|
||||
expected = list(range(expected_indexes))
|
||||
else:
|
||||
expected = list(expected_indexes)
|
||||
text = _JSON_FENCE_RE.sub("", content.strip()).strip()
|
||||
parse_errors: list[str] = []
|
||||
for candidate in (text, *_json_snippets(text)):
|
||||
try:
|
||||
parsed = _translations_from_json(json.loads(candidate), expected)
|
||||
except (TypeError, ValueError) as exc:
|
||||
parsed = None
|
||||
parse_errors.append(str(exc))
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
numbered: dict[int, str] = {}
|
||||
for line in text.splitlines():
|
||||
match = _NUMBERED_LINE_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
numbered[int(match.group(1))] = match.group(2).strip()
|
||||
by_original = {
|
||||
index: numbered[index]
|
||||
for index in expected
|
||||
if numbered.get(index, "").strip()
|
||||
}
|
||||
if len(by_original) == len(expected):
|
||||
return by_original
|
||||
by_order = {
|
||||
expected[offset]: numbered[offset + 1]
|
||||
for offset in range(len(expected))
|
||||
if numbered.get(offset + 1, "").strip()
|
||||
}
|
||||
if len(by_order) == len(expected):
|
||||
return by_order
|
||||
logger.warning(
|
||||
"Translation payload parser exhausted all formats: expected_indexes=%s "
|
||||
"json_parse_errors=%r numbered_line_keys=%s content=%r",
|
||||
expected,
|
||||
parse_errors,
|
||||
sorted(numbered),
|
||||
text[:2000],
|
||||
)
|
||||
raise AITutorError("AI translation returned an unexpected response.")
|
||||
|
||||
|
||||
def _json_snippets(text: str) -> list[str]:
|
||||
snippets: list[str] = []
|
||||
for opener, closer in (("{", "}"), ("[", "]")):
|
||||
start = text.find(opener)
|
||||
while start != -1:
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for offset, char in enumerate(text[start:], start=start):
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif char == "\\":
|
||||
escape = True
|
||||
elif char == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = True
|
||||
elif char == opener:
|
||||
depth += 1
|
||||
elif char == closer:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
snippets.append(text[start:offset + 1])
|
||||
break
|
||||
start = text.find(opener, start + 1)
|
||||
return snippets
|
||||
|
||||
|
||||
def _translations_from_json(data: object, expected: list[int]) -> dict[int, str] | None:
|
||||
expected_set = set(expected)
|
||||
if isinstance(data, dict):
|
||||
nested = None
|
||||
for key in ("translations", "sentences", "zh", "items"):
|
||||
if key in data:
|
||||
nested = data[key]
|
||||
break
|
||||
if nested is not None:
|
||||
parsed = _translations_from_json(nested, expected)
|
||||
if parsed:
|
||||
return parsed
|
||||
values: dict[int, str] = {}
|
||||
for raw_key, raw_value in data.items():
|
||||
try:
|
||||
index = int(raw_key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if index not in expected_set or not isinstance(raw_value, str) or not raw_value.strip():
|
||||
continue
|
||||
values[index] = raw_value.strip()
|
||||
if len(values) == len(expected):
|
||||
return values
|
||||
return None
|
||||
if isinstance(data, list) and len(data) == len(expected):
|
||||
if all(isinstance(item, str) and item.strip() for item in data):
|
||||
return {
|
||||
index: item.strip()
|
||||
for index, item in zip(expected, data)
|
||||
}
|
||||
if all(isinstance(item, dict) for item in data):
|
||||
values = {}
|
||||
for item in data:
|
||||
raw_index = item.get("index")
|
||||
zh = item.get("zh_text") or item.get("zh") or item.get("text")
|
||||
try:
|
||||
index = int(raw_index)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if index not in expected_set or not isinstance(zh, str) or not zh.strip():
|
||||
continue
|
||||
values[index] = zh.strip()
|
||||
if len(values) == len(expected):
|
||||
return values
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_translation_terms(terms: list[tuple[str, str]] | None) -> list[tuple[str, str]]:
|
||||
cleaned: list[tuple[str, str]] = []
|
||||
seen: dict[str, int] = {}
|
||||
for item in terms or []:
|
||||
if not isinstance(item, (tuple, list)) or len(item) < 2:
|
||||
continue
|
||||
source = str(item[0] or "").strip()[:120]
|
||||
zh = str(item[1] or "").strip()[:120]
|
||||
if not source or not zh:
|
||||
continue
|
||||
key = source.casefold()
|
||||
if key in seen:
|
||||
cleaned[seen[key]] = (source, zh)
|
||||
continue
|
||||
seen[key] = len(cleaned)
|
||||
cleaned.append((source, zh))
|
||||
if len(cleaned) >= 50:
|
||||
break
|
||||
return cleaned
|
||||
|
||||
|
||||
def _term_pattern(source: str) -> re.Pattern[str]:
|
||||
escaped = re.escape(source)
|
||||
if re.fullmatch(r"[A-Za-z][A-Za-z0-9'’.\- ]*", source):
|
||||
return re.compile(rf"(?<![A-Za-z0-9]){escaped}(?![A-Za-z0-9])", re.IGNORECASE)
|
||||
return re.compile(escaped)
|
||||
|
||||
|
||||
def _apply_translation_terms(
|
||||
source_text: str,
|
||||
zh_text: str,
|
||||
terms: list[tuple[str, str]] | None,
|
||||
) -> str:
|
||||
del source_text
|
||||
result = zh_text
|
||||
for source, zh in _normalize_translation_terms(terms):
|
||||
result = _term_pattern(source).sub(zh, result)
|
||||
return result
|
||||
@@ -28,6 +28,7 @@ class AssessmentService:
|
||||
audio_path: Path,
|
||||
language: Optional[str] = None,
|
||||
retained_audio_filename: Optional[str] = None,
|
||||
user_id: str = "",
|
||||
) -> AssessmentResult:
|
||||
if not self.transcriber.available:
|
||||
raise RuntimeError("MOSS transcription is not configured on this server.")
|
||||
@@ -103,5 +104,6 @@ class AssessmentService:
|
||||
sentence_index=sentence_index,
|
||||
result=result.model_dump(mode="json"),
|
||||
audio_filename=retained_audio_filename,
|
||||
user_id=user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
49
sentence_api/auth.py
Normal file
49
sentence_api/auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
PBKDF2_ITERATIONS = 120_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedUser:
|
||||
id: str
|
||||
username: str
|
||||
nickname: str
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("ascii"),
|
||||
PBKDF2_ITERATIONS,
|
||||
).hex()
|
||||
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt, digest = stored.split("$", 3)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
calculated = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("ascii"),
|
||||
int(iterations),
|
||||
).hex()
|
||||
return hmac.compare_digest(calculated, digest)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool) -> bool:
|
||||
@@ -25,7 +26,20 @@ class Settings:
|
||||
moss_timeout_seconds: float
|
||||
moss_max_new_tokens: int
|
||||
moss_end_padding_ms: int
|
||||
log_level: str
|
||||
ai_api_url: str
|
||||
ai_api_key: str
|
||||
ai_model: str
|
||||
ai_timeout_seconds: float
|
||||
ai_translation_timeout_seconds: float
|
||||
pass_score: float
|
||||
volcano_oss_access_key: str
|
||||
volcano_oss_secret_key: str
|
||||
volcano_oss_bucket: str
|
||||
volcano_oss_endpoint: str
|
||||
volcano_oss_region: str
|
||||
volcano_oss_key_prefix: str
|
||||
volcano_oss_public_base_url: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
@@ -53,7 +67,28 @@ class Settings:
|
||||
moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")),
|
||||
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
|
||||
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
|
||||
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
||||
ai_api_url=os.getenv("AI_API_URL", "").strip().rstrip("/"),
|
||||
ai_api_key=os.getenv("AI_API_KEY", "").strip(),
|
||||
ai_model=os.getenv("AI_MODEL", "gpt-4o-mini"),
|
||||
ai_timeout_seconds=float(os.getenv("AI_TIMEOUT_SECONDS", "45")),
|
||||
ai_translation_timeout_seconds=float(
|
||||
os.getenv("AI_TRANSLATION_TIMEOUT_SECONDS", "120")
|
||||
),
|
||||
pass_score=float(os.getenv("ASSESSMENT_PASS_SCORE", "70")),
|
||||
volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_KEY", "").strip(),
|
||||
volcano_oss_secret_key=os.getenv("VOLCANO_OSS_SECRET_KEY", "").strip(),
|
||||
volcano_oss_bucket=os.getenv("VOLCANO_OSS_BUCKET", "").strip(),
|
||||
volcano_oss_endpoint=os.getenv(
|
||||
"VOLCANO_OSS_ENDPOINT", "tos-cn-beijing.volces.com"
|
||||
).strip().strip("/"),
|
||||
volcano_oss_region=os.getenv("VOLCANO_OSS_REGION", "cn-beijing").strip(),
|
||||
volcano_oss_key_prefix=Settings._normalize_key_prefix(
|
||||
os.getenv("VOLCANO_OSS_KEY_PREFIX", "videos/")
|
||||
),
|
||||
volcano_oss_public_base_url=os.getenv(
|
||||
"VOLCANO_OSS_PUBLIC_BASE_URL", ""
|
||||
).strip().rstrip("/"),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -76,6 +111,34 @@ class Settings:
|
||||
def database_path(self) -> Path:
|
||||
return self.data_dir / "oral_trainer.sqlite3"
|
||||
|
||||
@property
|
||||
def volcano_oss_enabled(self) -> bool:
|
||||
return all(
|
||||
(
|
||||
self.volcano_oss_access_key,
|
||||
self.volcano_oss_secret_key,
|
||||
self.volcano_oss_bucket,
|
||||
self.volcano_oss_endpoint,
|
||||
self.volcano_oss_region,
|
||||
)
|
||||
)
|
||||
|
||||
def volcano_oss_public_url(self, object_key: str) -> str:
|
||||
quoted_key = quote(object_key)
|
||||
if self.volcano_oss_public_base_url:
|
||||
return f"{self.volcano_oss_public_base_url}/{quoted_key}"
|
||||
endpoint = self.volcano_oss_endpoint
|
||||
if "://" in endpoint:
|
||||
scheme, host = endpoint.split("://", 1)
|
||||
else:
|
||||
scheme, host = "https", endpoint
|
||||
return f"{scheme}://{self.volcano_oss_bucket}.{host}/{quoted_key}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_key_prefix(value: str) -> str:
|
||||
normalized = value.strip().strip("/")
|
||||
return f"{normalized}/" if normalized else ""
|
||||
|
||||
def ensure_directories(self) -> None:
|
||||
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir, self.dub_shares_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -3,6 +3,11 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: sentence_api/Dockerfile
|
||||
args:
|
||||
PYTHON_IMAGE: ${PYTHON_IMAGE:-docker.m.daocloud.io/library/python:3.12-slim}
|
||||
APT_MIRROR: ${APT_MIRROR:-mirrors.aliyun.com}
|
||||
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-pypi.tuna.tsinghua.edu.cn}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
|
||||
5
sentence_api/docker-daemon.json.example
Normal file
5
sentence_api/docker-daemon.json.example
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.m.daocloud.io"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user