Compare commits
89 Commits
6de1d4a3d0
...
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ sentence_api/data/v/
|
|||||||
sentence_api/data/work/
|
sentence_api/data/work/
|
||||||
sentence_api/data/attempts/
|
sentence_api/data/attempts/
|
||||||
android/sample-app/release/
|
android/sample-app/release/
|
||||||
|
|
||||||
|
.tmp/
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -10,8 +10,17 @@ pip 安装依赖后即可直接播放视频。
|
|||||||
- 播放 / 暂停 / 停止、上一首 / 下一首
|
- 播放 / 暂停 / 停止、上一首 / 下一首
|
||||||
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
|
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
|
||||||
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
|
- 快进步长可在界面底部直接填写(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 变速播放
|
- 音量调节、静音,0.5x - 2.0x 变速播放
|
||||||
- 三种循环模式:关 / 单曲循环 / 列表循环
|
- 三种循环模式:关 / 单曲循环 / 列表循环
|
||||||
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
||||||
@@ -77,6 +86,8 @@ pip install -r requirements.txt
|
|||||||
| `N` / `P` | 下一首 / 上一首 |
|
| `N` / `P` | 下一首 / 上一首 |
|
||||||
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
|
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
|
||||||
| `L` | 切换循环模式(关 / 单曲 / 列表) |
|
| `L` | 切换循环模式(关 / 单曲 / 列表) |
|
||||||
|
| `C` | 开 / 关中文字幕 |
|
||||||
|
| 双击画面 | 进入 / 退出全屏 |
|
||||||
| `F` | 视频全屏切换 |
|
| `F` | 视频全屏切换 |
|
||||||
| `Esc` | 退出全屏 |
|
| `Esc` | 退出全屏 |
|
||||||
| `Ctrl+O` | 打开文件(macOS 亦支持 `Command+O`) |
|
| `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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import androidx.media3.common.C
|
|||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.PlayerMessage
|
||||||
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
@@ -30,6 +33,15 @@ class OralTrainerController internal constructor(
|
|||||||
private var lastSentenceIndex: Int? = null
|
private var lastSentenceIndex: Int? = null
|
||||||
private var stopAtSentenceEnd = false
|
private var stopAtSentenceEnd = false
|
||||||
private var stopAtSentenceEndIndex: Int? = null
|
private var stopAtSentenceEndIndex: Int? = null
|
||||||
|
private var 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
|
var config: PlayerConfig = initialConfig
|
||||||
private set
|
private set
|
||||||
@@ -44,10 +56,10 @@ class OralTrainerController internal constructor(
|
|||||||
if (released) {
|
if (released) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
enforceSentenceBoundary()
|
enforceStopAtSentenceEnd()
|
||||||
notifySentenceIfChanged()
|
notifySentenceIfChanged()
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
mainHandler.postDelayed(this, 250L)
|
mainHandler.postDelayed(this, IDLE_POLL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +69,45 @@ class OralTrainerController internal constructor(
|
|||||||
)
|
)
|
||||||
player = ExoPlayer.Builder(appContext)
|
player = ExoPlayer.Builder(appContext)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
|
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||||
.build()
|
.build()
|
||||||
.also { exoPlayer ->
|
.also { exoPlayer ->
|
||||||
exoPlayer.repeatMode = Player.REPEAT_MODE_ALL
|
exoPlayer.repeatMode = Player.REPEAT_MODE_ALL
|
||||||
exoPlayer.playWhenReady = initialConfig.autoPlay
|
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 {
|
exoPlayer.addListener(object : Player.Listener {
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
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()
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +116,14 @@ class OralTrainerController internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
if (updatingPlaybackWindow) {
|
||||||
|
notifySnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (clippedSentence == null) {
|
||||||
|
baseItemIndex = player.currentMediaItemIndex
|
||||||
lastSentenceIndex = null
|
lastSentenceIndex = null
|
||||||
|
}
|
||||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
@@ -129,9 +181,18 @@ class OralTrainerController internal constructor(
|
|||||||
lastSentenceIndex = null
|
lastSentenceIndex = null
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = null
|
stopAtSentenceEndIndex = null
|
||||||
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs))
|
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.prepare()
|
||||||
player.playWhenReady = config.autoPlay
|
player.playWhenReady = config.autoPlay
|
||||||
|
applyLoopMode()
|
||||||
|
lockSentenceAt(startMs)
|
||||||
|
syncPlaybackWindow(startMs)
|
||||||
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
|
||||||
notifySentenceIfChanged(force = true)
|
notifySentenceIfChanged(force = true)
|
||||||
notifySnapshot()
|
notifySnapshot()
|
||||||
@@ -141,14 +202,46 @@ class OralTrainerController internal constructor(
|
|||||||
loadItems(listOf(item))
|
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() {
|
fun play() {
|
||||||
|
syncPlaybackWindow()
|
||||||
|
if (clippedSentence != null && !player.isPlaying && shouldWarmClipStart()) {
|
||||||
|
playClippedSentenceFromStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = null
|
stopAtSentenceEndIndex = null
|
||||||
|
cancelClipStartWarmup(restoreVolume = true)
|
||||||
|
cancelSentenceEndGuard()
|
||||||
player.pause()
|
player.pause()
|
||||||
|
applySentenceRepeatMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
@@ -162,17 +255,26 @@ class OralTrainerController internal constructor(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = null
|
stopAtSentenceEndIndex = null
|
||||||
|
cancelClipStartWarmup(restoreVolume = true)
|
||||||
|
cancelSentenceEndGuard()
|
||||||
player.stop()
|
player.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = null
|
stopAtSentenceEndIndex = null
|
||||||
player.seekTo(max(0L, positionMs))
|
cancelClipStartWarmup(restoreVolume = true)
|
||||||
|
cancelSentenceEndGuard()
|
||||||
|
val safePositionMs = max(0L, positionMs)
|
||||||
|
lockSentenceAt(safePositionMs)
|
||||||
|
syncPlaybackWindow(safePositionMs)
|
||||||
|
seekInCurrentWindow(safePositionMs)
|
||||||
|
notifySentenceIfChanged(force = true)
|
||||||
|
notifySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekRelative(deltaMs: Long) {
|
fun seekRelative(deltaMs: Long) {
|
||||||
seekTo(player.currentPosition + deltaMs)
|
seekTo(playbackPositionMs() + deltaMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rewind(stepMs: Long = config.defaultSeekStepMs) {
|
fun rewind(stepMs: Long = config.defaultSeekStepMs) {
|
||||||
@@ -184,11 +286,11 @@ class OralTrainerController internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun previousItem() {
|
fun previousItem() {
|
||||||
player.seekToPreviousMediaItem()
|
switchBaseItem(baseItemIndex - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nextItem() {
|
fun nextItem() {
|
||||||
player.seekToNextMediaItem()
|
switchBaseItem(baseItemIndex + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlaybackSpeed(speed: Float) {
|
fun setPlaybackSpeed(speed: Float) {
|
||||||
@@ -202,40 +304,71 @@ class OralTrainerController internal constructor(
|
|||||||
|
|
||||||
fun setLoopMode(loopMode: LoopMode) {
|
fun setLoopMode(loopMode: LoopMode) {
|
||||||
this.loopMode = loopMode
|
this.loopMode = loopMode
|
||||||
player.repeatMode = when (loopMode) {
|
applySentenceRepeatMode()
|
||||||
LoopMode.OFF -> Player.REPEAT_MODE_OFF
|
|
||||||
LoopMode.ONE -> Player.REPEAT_MODE_ONE
|
|
||||||
LoopMode.ALL -> Player.REPEAT_MODE_ALL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSentenceMode(enabled: Boolean) {
|
fun setSentenceMode(enabled: Boolean) {
|
||||||
config = config.copy(sentenceMode = enabled)
|
config = config.copy(sentenceMode = enabled)
|
||||||
|
syncPlaybackWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setContinuousPlayback(enabled: Boolean) {
|
fun setContinuousPlayback(enabled: Boolean) {
|
||||||
config = config.copy(continuousPlayback = enabled)
|
config = config.copy(continuousPlayback = enabled)
|
||||||
|
if (!enabled) {
|
||||||
|
lockSentenceAt(playbackPositionMs())
|
||||||
|
}
|
||||||
|
syncPlaybackWindow()
|
||||||
|
notifySentenceIfChanged(force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playCurrentSentenceAndStop() {
|
fun playCurrentSentenceAndStop() {
|
||||||
val sentence = currentSentence() ?: return
|
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
|
stopAtSentenceEnd = true
|
||||||
stopAtSentenceEndIndex = sentence.index
|
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()
|
player.play()
|
||||||
}
|
}
|
||||||
|
notifySentenceIfChanged(force = true)
|
||||||
|
notifySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
fun currentTrainingItem(): TrainingMediaItem? {
|
fun currentTrainingItem(): TrainingMediaItem? {
|
||||||
val index = player.currentMediaItemIndex
|
return mediaItems.getOrNull(baseItemIndex)
|
||||||
return mediaItems.getOrNull(index)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentSentence(): SentenceBoundary? {
|
fun currentSentence(): SentenceBoundary? {
|
||||||
val item = currentTrainingItem() ?: return null
|
val item = currentTrainingItem() ?: return null
|
||||||
val position = player.currentPosition
|
if (item.sentences.isEmpty()) {
|
||||||
return item.sentences.lastOrNull { sentence ->
|
return null
|
||||||
position >= sentence.startMs && position < sentence.endMs
|
|
||||||
}
|
}
|
||||||
|
clippedSentence?.let { window ->
|
||||||
|
return item.sentences.getOrNull(window.index)
|
||||||
|
}
|
||||||
|
if (shouldBoundCurrentSentence()) {
|
||||||
|
lastSentenceIndex?.let { index ->
|
||||||
|
item.sentences.getOrNull(index)?.let { return it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sentenceAt(item, playbackPositionMs())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekToPreviousSentence(): Boolean {
|
fun seekToPreviousSentence(): Boolean {
|
||||||
@@ -243,11 +376,16 @@ class OralTrainerController internal constructor(
|
|||||||
if (item.sentences.isEmpty()) {
|
if (item.sentences.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val position = player.currentPosition
|
val target = if (shouldBoundCurrentSentence()) {
|
||||||
val target = item.sentences
|
val index = anchoredSentenceIndex(item) ?: 0
|
||||||
|
item.sentences.getOrNull((index - 1).coerceAtLeast(0)) ?: item.sentences.first()
|
||||||
|
} else {
|
||||||
|
val position = playbackPositionMs()
|
||||||
|
item.sentences
|
||||||
.asReversed()
|
.asReversed()
|
||||||
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
|
||||||
?: item.sentences.first()
|
?: item.sentences.first()
|
||||||
|
}
|
||||||
seekTo(target.startMs)
|
seekTo(target.startMs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -257,10 +395,15 @@ class OralTrainerController internal constructor(
|
|||||||
if (item.sentences.isEmpty()) {
|
if (item.sentences.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val position = player.currentPosition
|
val target = if (shouldBoundCurrentSentence()) {
|
||||||
val target = item.sentences.firstOrNull {
|
val index = anchoredSentenceIndex(item) ?: return false
|
||||||
|
item.sentences.getOrNull(index + 1) ?: return false
|
||||||
|
} else {
|
||||||
|
val position = playbackPositionMs()
|
||||||
|
item.sentences.firstOrNull {
|
||||||
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
|
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
|
||||||
} ?: return false
|
} ?: return false
|
||||||
|
}
|
||||||
seekTo(target.startMs)
|
seekTo(target.startMs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -317,12 +460,16 @@ class OralTrainerController internal constructor(
|
|||||||
|
|
||||||
fun snapshot(): PlaybackSnapshot {
|
fun snapshot(): PlaybackSnapshot {
|
||||||
val item = currentTrainingItem()
|
val item = currentTrainingItem()
|
||||||
val duration = player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L
|
val duration = when {
|
||||||
|
unclippedDurationMs > 0L -> unclippedDurationMs
|
||||||
|
clippedSentence != null -> clippedSentence?.endMs ?: -1L
|
||||||
|
else -> player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L
|
||||||
|
}
|
||||||
return PlaybackSnapshot(
|
return PlaybackSnapshot(
|
||||||
mediaId = item?.id,
|
mediaId = item?.id,
|
||||||
positionMs = max(0L, player.currentPosition),
|
positionMs = playbackPositionMs(),
|
||||||
durationMs = duration,
|
durationMs = duration,
|
||||||
bufferedPositionMs = max(0L, player.bufferedPosition),
|
bufferedPositionMs = playbackBufferedPositionMs(),
|
||||||
isPlaying = player.isPlaying,
|
isPlaying = player.isPlaying,
|
||||||
playbackState = player.playbackState.toOralTrainerState(),
|
playbackState = player.playbackState.toOralTrainerState(),
|
||||||
playbackSpeed = player.playbackParameters.speed,
|
playbackSpeed = player.playbackParameters.speed,
|
||||||
@@ -335,6 +482,8 @@ class OralTrainerController internal constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
released = true
|
released = true
|
||||||
|
cancelClipStartWarmup(restoreVolume = false)
|
||||||
|
cancelSentenceEndGuard()
|
||||||
mainHandler.removeCallbacks(ticker)
|
mainHandler.removeCallbacks(ticker)
|
||||||
listeners.clear()
|
listeners.clear()
|
||||||
player.release()
|
player.release()
|
||||||
@@ -358,42 +507,291 @@ class OralTrainerController internal constructor(
|
|||||||
listeners.forEach { it.onSentenceChanged(sentence) }
|
listeners.forEach { it.onSentenceChanged(sentence) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enforceSentenceBoundary() {
|
private fun syncPlaybackWindow(positionMs: Long = playbackPositionMs()) {
|
||||||
if (!config.sentenceMode || !player.isPlaying) {
|
if (released || updatingPlaybackWindow) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val item = currentTrainingItem() ?: return
|
val item = currentTrainingItem()
|
||||||
if (item.sentences.isEmpty()) {
|
val target = sentenceForWindow(item)
|
||||||
|
if (target == null || item == null) {
|
||||||
|
restoreUnclipped(positionMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val position = player.currentPosition
|
val current = clippedSentence
|
||||||
if (stopAtSentenceEnd) {
|
if (current != null &&
|
||||||
val index = stopAtSentenceEndIndex ?: return
|
current.index == target.index &&
|
||||||
val sentence = item.sentences.getOrNull(index) ?: return
|
current.startMs == target.startMs &&
|
||||||
if (position >= sentence.endMs) {
|
current.endMs == target.endMs
|
||||||
stopAtSentenceEnd = false
|
) {
|
||||||
stopAtSentenceEndIndex = null
|
applySentenceRepeatMode()
|
||||||
player.pause()
|
|
||||||
player.seekTo(sentence.startMs)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (config.continuousPlayback) {
|
val relativeMs = (positionMs - target.startMs).coerceAtLeast(0L)
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
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()
|
val builder = MediaItem.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
.setUri(uri)
|
.setUri(uri)
|
||||||
mimeType?.let(builder::setMimeType)
|
mimeType?.let(builder::setMimeType)
|
||||||
customCacheKey?.let(builder::setCustomCacheKey)
|
customCacheKey?.let(builder::setCustomCacheKey)
|
||||||
|
if (clippingEndPositionMs != null) {
|
||||||
|
builder.setClippingConfiguration(
|
||||||
|
MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setStartPositionMs(max(0L, clippingStartPositionMs))
|
||||||
|
.setEndPositionMs(max(0L, clippingEndPositionMs))
|
||||||
|
.setStartsAtKeyFrame(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
subtitleUri?.let { uri ->
|
subtitleUri?.let { uri ->
|
||||||
builder.setSubtitleConfigurations(
|
builder.setSubtitleConfigurations(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -422,8 +820,93 @@ class OralTrainerController internal constructor(
|
|||||||
return CancellableAssessment {}
|
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 {
|
private companion object {
|
||||||
const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L
|
const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L
|
||||||
const val NEXT_SENTENCE_TOLERANCE_MS = 150L
|
const val NEXT_SENTENCE_TOLERANCE_MS = 150L
|
||||||
|
const val IDLE_POLL_MS = 250L
|
||||||
|
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.util.AttributeSet
|
||||||
import android.view.GestureDetector
|
import android.view.GestureDetector
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewConfiguration
|
import android.view.ViewConfiguration
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
@@ -16,6 +17,7 @@ class OralTrainerPlayerView @JvmOverloads constructor(
|
|||||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
val playerView: PlayerView = PlayerView(context).apply {
|
val playerView: PlayerView = PlayerView(context).apply {
|
||||||
useController = false
|
useController = false
|
||||||
|
setKeepContentOnPlayerReset(true)
|
||||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +124,26 @@ class OralTrainerPlayerView @JvmOverloads constructor(
|
|||||||
verticalSwipeListener = listener
|
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 {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
|
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class OralTrainerSdk private constructor(
|
|||||||
|
|
||||||
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
|
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
|
||||||
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
|
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
|
||||||
|
val userApi: UserApi = UserApi(appContext, config)
|
||||||
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
|
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
|
||||||
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
|
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
|
|||||||
val readTimeoutMs: Int = 30_000,
|
val readTimeoutMs: Int = 30_000,
|
||||||
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
||||||
val assessmentApiKey: String? = null,
|
val assessmentApiKey: String? = null,
|
||||||
|
var userToken: String? = null,
|
||||||
val allowHttpFallback: Boolean = false,
|
val allowHttpFallback: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor(
|
|||||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||||
connection.setRequestProperty("X-Client-Key", it)
|
connection.setRequestProperty("X-Client-Key", it)
|
||||||
}
|
}
|
||||||
|
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
BufferedOutputStream(connection.outputStream).use { output ->
|
BufferedOutputStream(connection.outputStream).use { output ->
|
||||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||||
writeFilePart(output, boundary, request.recordingUri)
|
writeFilePart(output, boundary, request.recordingUri)
|
||||||
|
|||||||
@@ -111,8 +111,12 @@ class SentenceBoundaryApi internal constructor(
|
|||||||
connection.requestMethod = "GET"
|
connection.requestMethod = "GET"
|
||||||
connection.connectTimeout = config.connectTimeoutMs
|
connection.connectTimeout = config.connectTimeoutMs
|
||||||
connection.readTimeout = config.readTimeoutMs
|
connection.readTimeout = config.readTimeoutMs
|
||||||
|
connection.useCaches = false
|
||||||
connection.setRequestProperty("Accept", "application/json")
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||||
|
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
val statusCode = connection.responseCode
|
val statusCode = connection.responseCode
|
||||||
val stream = if (statusCode in 200..299) {
|
val stream = if (statusCode in 200..299) {
|
||||||
connection.inputStream
|
connection.inputStream
|
||||||
@@ -155,6 +159,11 @@ class SentenceBoundaryApi internal constructor(
|
|||||||
} else {
|
} else {
|
||||||
sentence.getLong("reference_speech_duration_ms")
|
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 text: String? = null,
|
||||||
val language: String? = null,
|
val language: String? = null,
|
||||||
val referenceSpeechDurationMs: Long? = null,
|
val referenceSpeechDurationMs: Long? = null,
|
||||||
|
val zhText: String? = null,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
require(index >= 0) { "Sentence index must be non-negative." }
|
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
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.2"
|
versionName = "0.1.6"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -37,6 +37,7 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":oral-trainer-sdk"))
|
implementation(project(":oral-trainer-sdk"))
|
||||||
implementation("androidx.core:core-ktx:1.13.1")
|
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-common:1.11.0")
|
||||||
implementation("androidx.media3:media3-exoplayer: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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -19,6 +22,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".TrainingPlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">八哥口语</string>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">MynaTalk</string>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
||||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||||
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
||||||
@@ -30,9 +35,15 @@
|
|||||||
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
|
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
|
||||||
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
|
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
|
||||||
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -58,6 +69,7 @@
|
|||||||
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; 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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -117,10 +129,15 @@
|
|||||||
children = (
|
children = (
|
||||||
AA0000000000000000000028 /* HTTPClient.swift */,
|
AA0000000000000000000028 /* HTTPClient.swift */,
|
||||||
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
||||||
|
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||||
|
AA00000000000000000000A4 /* TrainingHistoryStore.swift */,
|
||||||
|
AA00000000000000000000B1 /* SentenceExplanationStore.swift */,
|
||||||
|
AA00000000000000000000A2 /* UserHistory.swift */,
|
||||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||||
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
||||||
|
AA00000000000000000000A6 /* StreamingVideoCache.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -139,6 +156,7 @@
|
|||||||
children = (
|
children = (
|
||||||
AA000000000000000000002E /* Theme.swift */,
|
AA000000000000000000002E /* Theme.swift */,
|
||||||
AA000000000000000000002F /* RootView.swift */,
|
AA000000000000000000002F /* RootView.swift */,
|
||||||
|
AA0000000000000000000093 /* AuthView.swift */,
|
||||||
AA0000000000000000000030 /* TrainingView.swift */,
|
AA0000000000000000000030 /* TrainingView.swift */,
|
||||||
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
||||||
AA0000000000000000000032 /* CatalogView.swift */,
|
AA0000000000000000000032 /* CatalogView.swift */,
|
||||||
@@ -241,6 +259,7 @@
|
|||||||
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
|
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
|
||||||
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
|
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
|
||||||
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
|
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
|
||||||
|
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */,
|
||||||
AA000000000000000000004C /* PlayerController.swift in Sources */,
|
AA000000000000000000004C /* PlayerController.swift in Sources */,
|
||||||
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
|
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
|
||||||
AA000000000000000000004E /* Theme.swift in Sources */,
|
AA000000000000000000004E /* Theme.swift in Sources */,
|
||||||
@@ -248,6 +267,11 @@
|
|||||||
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
||||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||||
AA0000000000000000000052 /* CatalogView.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 */,
|
AA0000000000000000000053 /* TestView.swift in Sources */,
|
||||||
AA0000000000000000000054 /* FullscreenPlayerView.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 {
|
) -> UIInterfaceOrientationMask {
|
||||||
AppDelegate.orientationLock
|
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 connectTimeout: TimeInterval = 15
|
||||||
static let readTimeout: TimeInterval = 30
|
static let readTimeout: TimeInterval = 30
|
||||||
static let assessmentTimeout: TimeInterval = 180
|
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}$"#
|
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
|
case longPressSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SwipeBackResult {
|
||||||
|
case restartedCurrent
|
||||||
|
case movedPrevious
|
||||||
|
case stayedAtFirst
|
||||||
|
case rewound
|
||||||
|
}
|
||||||
|
|
||||||
enum BoundaryState: Equatable {
|
enum BoundaryState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case loading(isLocal: Bool)
|
case loading(isLocal: Bool)
|
||||||
case loaded(count: Int)
|
case loaded(count: Int)
|
||||||
case failed(message: String)
|
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 text: String?
|
||||||
let language: String?
|
let language: String?
|
||||||
let referenceSpeechDurationMs: Int64?
|
let referenceSpeechDurationMs: Int64?
|
||||||
|
let zhText: String?
|
||||||
|
|
||||||
var id: Int { index }
|
var id: Int { index }
|
||||||
|
|
||||||
@@ -15,6 +16,48 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
|
|||||||
case startMs = "start_ms"
|
case startMs = "start_ms"
|
||||||
case endMs = "end_ms"
|
case endMs = "end_ms"
|
||||||
case referenceSpeechDurationMs = "reference_speech_duration_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)
|
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 {
|
struct TrainingMediaItem: Identifiable, Equatable {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class PlayerController: ObservableObject {
|
|||||||
@Published private(set) var currentSentence: SentenceBoundary?
|
@Published private(set) var currentSentence: SentenceBoundary?
|
||||||
@Published var boundaryState: BoundaryState = .idle
|
@Published var boundaryState: BoundaryState = .idle
|
||||||
@Published var transientMessage: String?
|
@Published var transientMessage: String?
|
||||||
|
@Published private(set) var cacheState: VideoCacheState = .notRequired
|
||||||
|
|
||||||
let player = AVPlayer()
|
let player = AVPlayer()
|
||||||
|
|
||||||
@@ -18,10 +19,30 @@ final class PlayerController: ObservableObject {
|
|||||||
|
|
||||||
private var mediaItem: TrainingMediaItem?
|
private var mediaItem: TrainingMediaItem?
|
||||||
private var lastSentenceIndex: Int?
|
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 stopAtSentenceEnd = false
|
||||||
private var stopAtSentenceEndIndex: Int?
|
private var stopAtSentenceEndIndex: Int?
|
||||||
|
private var swipeBackAnchorIndex: Int?
|
||||||
|
private var swipeBackAnchorTime: TimeInterval = 0
|
||||||
private var speedBeforeLongPress: Float?
|
private var speedBeforeLongPress: Float?
|
||||||
private var transientTask: Task<Void, Never>?
|
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 timeObserver: Any?
|
||||||
private var endObserver: NSObjectProtocol?
|
private var endObserver: NSObjectProtocol?
|
||||||
@@ -31,6 +52,7 @@ final class PlayerController: ObservableObject {
|
|||||||
init() {
|
init() {
|
||||||
configureAudioSession()
|
configureAudioSession()
|
||||||
player.automaticallyWaitsToMinimizeStalling = true
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
|
player.actionAtItemEnd = .pause
|
||||||
timeObserver = player.addPeriodicTimeObserver(
|
timeObserver = player.addPeriodicTimeObserver(
|
||||||
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
|
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
|
||||||
queue: .main
|
queue: .main
|
||||||
@@ -46,6 +68,7 @@ final class PlayerController: ObservableObject {
|
|||||||
}
|
}
|
||||||
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
|
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
self?.refreshSentenceBoundaryGuard()
|
||||||
self?.tick()
|
self?.tick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,37 +82,76 @@ final class PlayerController: ObservableObject {
|
|||||||
if let endObserver = endObserver {
|
if let endObserver = endObserver {
|
||||||
NotificationCenter.default.removeObserver(endObserver)
|
NotificationCenter.default.removeObserver(endObserver)
|
||||||
}
|
}
|
||||||
|
cancelSentenceBoundaryGuard()
|
||||||
itemStatusObserver?.invalidate()
|
itemStatusObserver?.invalidate()
|
||||||
timeControlObserver?.invalidate()
|
timeControlObserver?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 装载
|
// MARK: - 装载
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func load(_ item: TrainingMediaItem) {
|
func load(_ item: TrainingMediaItem) {
|
||||||
|
cancelSentenceBoundaryGuard()
|
||||||
|
cacheFallbackTask?.cancel()
|
||||||
|
cacheFallbackTask = nil
|
||||||
|
didAttemptFullFileFallback = false
|
||||||
lastSentenceIndex = nil
|
lastSentenceIndex = nil
|
||||||
|
swipeBackAnchorIndex = nil
|
||||||
|
pendingSeekPositionMs = nil
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
|
playbackIntent = false
|
||||||
mediaItem = item
|
mediaItem = item
|
||||||
currentItem = item
|
currentItem = item
|
||||||
boundaryState = .idle
|
boundaryState = .idle
|
||||||
let playerItem = AVPlayerItem(url: item.url)
|
|
||||||
attachItemObservers(to: playerItem)
|
if item.isLocalFile {
|
||||||
player.replaceCurrentItem(with: playerItem)
|
cacheState = .notRequired
|
||||||
if config.autoPlay {
|
preparePlaybackItem(with: item.url)
|
||||||
player.play()
|
} 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()
|
tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 播放控制
|
// MARK: - 播放控制
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
|
playbackIntent = true
|
||||||
player.play()
|
player.play()
|
||||||
|
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
|
||||||
|
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
|
||||||
|
refreshSentenceBoundaryGuard(force: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
|
playbackIntent = false
|
||||||
|
cancelSentenceBoundaryGuard()
|
||||||
player.pause()
|
player.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,19 +170,20 @@ final class PlayerController: ObservableObject {
|
|||||||
func stop() {
|
func stop() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
|
playbackIntent = false
|
||||||
|
cancelSentenceBoundaryGuard()
|
||||||
player.pause()
|
player.pause()
|
||||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
seek(to: .zero)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to positionMs: Int64) {
|
func seek(to positionMs: Int64) {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
performSeek(to: positionMs)
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekRelative(_ deltaMs: Int64) {
|
func seekRelative(_ deltaMs: Int64) {
|
||||||
seek(to: snapshot.positionMs + deltaMs)
|
seek(to: referencePositionMs() + deltaMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewind() {
|
func rewind() {
|
||||||
@@ -138,12 +201,14 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let position = snapshot.positionMs
|
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
|
||||||
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
|
// 播放点落在两句之间的空隙时也能正确定位前一句。
|
||||||
?? item.sentences.first else {
|
let position = referencePositionMs()
|
||||||
return false
|
let target = item.sentences
|
||||||
}
|
.reversed()
|
||||||
seek(to: target.startMs)
|
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
|
||||||
|
?? item.sentences[0]
|
||||||
|
seekToSentenceBeginning(target)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,14 +217,49 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let position = snapshot.positionMs
|
// Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间
|
||||||
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
|
// 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。
|
||||||
|
let position = referencePositionMs()
|
||||||
|
guard let target = item.sentences.first(where: {
|
||||||
|
$0.startMs > position + Self.nextSentenceToleranceMs
|
||||||
|
}) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
seek(to: target.startMs)
|
seekToSentenceBeginning(target)
|
||||||
return true
|
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() {
|
func previousSentenceOrRewind() {
|
||||||
if !config.sentenceMode || !seekToPreviousSentence() {
|
if !config.sentenceMode || !seekToPreviousSentence() {
|
||||||
rewind()
|
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() {
|
func playCurrentSentenceAndStop() {
|
||||||
guard let sentence = currentSentence else {
|
guard let sentence = currentSentence else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600)
|
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
stopAtSentenceEnd = true
|
stopAtSentenceEnd = true
|
||||||
stopAtSentenceEndIndex = sentence.index
|
stopAtSentenceEndIndex = sentence.index
|
||||||
play()
|
lastSentenceIndex = sentence.index
|
||||||
|
playbackIntent = true
|
||||||
|
performSeek(to: sentenceSeekPosition(sentence), resumePlayback: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 设置
|
// MARK: - 设置
|
||||||
|
|
||||||
func setContinuousPlayback(_ enabled: Bool) {
|
func setContinuousPlayback(_ enabled: Bool) {
|
||||||
config.continuousPlayback = enabled
|
config.continuousPlayback = enabled
|
||||||
|
if !enabled {
|
||||||
|
lastSentenceIndex = currentSentence?.index ?? lastSentenceIndex
|
||||||
|
}
|
||||||
|
refreshSentenceBoundaryGuard(force: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSentenceMode(_ enabled: Bool) {
|
func setSentenceMode(_ enabled: Bool) {
|
||||||
config.sentenceMode = enabled
|
config.sentenceMode = enabled
|
||||||
|
refreshSentenceBoundaryGuard()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setLoopMode(_ mode: LoopMode) {
|
func setLoopMode(_ mode: LoopMode) {
|
||||||
@@ -248,8 +398,16 @@ final class PlayerController: ObservableObject {
|
|||||||
togglePlayPause()
|
togglePlayPause()
|
||||||
showTransientMessage("播放状态已切换")
|
showTransientMessage("播放状态已切换")
|
||||||
case .swipeLeft:
|
case .swipeLeft:
|
||||||
previousSentenceOrRewind()
|
switch replayOrPreviousSentence() {
|
||||||
|
case .restartedCurrent:
|
||||||
|
showTransientMessage("已回到本句开头,再左划返回上一句")
|
||||||
|
case .movedPrevious:
|
||||||
showTransientMessage("已跳到上一句")
|
showTransientMessage("已跳到上一句")
|
||||||
|
case .stayedAtFirst:
|
||||||
|
showTransientMessage("已是第一句")
|
||||||
|
case .rewound:
|
||||||
|
showTransientMessage("已快退")
|
||||||
|
}
|
||||||
case .swipeRight:
|
case .swipeRight:
|
||||||
nextSentenceOrForward()
|
nextSentenceOrForward()
|
||||||
showTransientMessage("已跳到下一句")
|
showTransientMessage("已跳到下一句")
|
||||||
@@ -277,13 +435,21 @@ final class PlayerController: ObservableObject {
|
|||||||
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
|
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let position = currentPositionMs()
|
let position = referencePositionMs()
|
||||||
|
if pendingSeekPositionMs == nil, lastSentenceIndex == nil {
|
||||||
|
lastSentenceIndex = sentence(at: position)?.index
|
||||||
|
}
|
||||||
|
if pendingSeekPositionMs == nil {
|
||||||
enforceSentenceBoundary(positionMs: position)
|
enforceSentenceBoundary(positionMs: position)
|
||||||
let finalPosition = currentPositionMs()
|
}
|
||||||
|
let finalPosition = referencePositionMs()
|
||||||
let sentence = sentence(at: finalPosition)
|
let sentence = sentence(at: finalPosition)
|
||||||
if sentence?.index != lastSentenceIndex {
|
if sentence?.index != lastSentenceIndex {
|
||||||
lastSentenceIndex = sentence?.index
|
lastSentenceIndex = sentence?.index
|
||||||
}
|
}
|
||||||
|
if sentence?.index != swipeBackAnchorIndex {
|
||||||
|
swipeBackAnchorIndex = nil
|
||||||
|
}
|
||||||
let duration = durationMs(of: item)
|
let duration = durationMs(of: item)
|
||||||
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
|
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
|
||||||
snapshot = PlaybackSnapshot(
|
snapshot = PlaybackSnapshot(
|
||||||
@@ -307,6 +473,77 @@ final class PlayerController: ObservableObject {
|
|||||||
return max(0, Int64(seconds * 1000))
|
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 {
|
private func durationMs(of item: AVPlayerItem) -> Int64 {
|
||||||
let seconds = item.duration.seconds
|
let seconds = item.duration.seconds
|
||||||
guard seconds.isFinite, seconds > 0 else {
|
guard seconds.isFinite, seconds > 0 else {
|
||||||
@@ -388,13 +625,137 @@ final class PlayerController: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if positionMs >= anchor.endMs {
|
if positionMs >= anchor.endMs {
|
||||||
seekRaw(anchor.startMs)
|
seekRaw(sentenceSeekPosition(anchor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func seekRaw(_ positionMs: Int64) {
|
private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) {
|
||||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
performSeek(to: positionMs, completion: completion)
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
private func handleEnded() {
|
||||||
@@ -402,9 +763,53 @@ final class PlayerController: ObservableObject {
|
|||||||
tick()
|
tick()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
|
||||||
player.play()
|
// 不能再被识别成整条视频结束。
|
||||||
|
guard pendingSeekPositionMs == nil else {
|
||||||
tick()
|
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: - 观察器
|
// MARK: - 观察器
|
||||||
@@ -419,12 +824,57 @@ final class PlayerController: ObservableObject {
|
|||||||
if item.status == .failed {
|
if item.status == .failed {
|
||||||
let message = item.error?.localizedDescription ?? "未知错误"
|
let message = item.error?.localizedDescription ?? "未知错误"
|
||||||
self.showTransientMessage("播放失败:\(message)")
|
self.showTransientMessage("播放失败:\(message)")
|
||||||
|
} else if item.status == .readyToPlay {
|
||||||
|
self.considerFullFileFallback(for: item)
|
||||||
}
|
}
|
||||||
self.tick()
|
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() {
|
private func configureAudioSession() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
try? session.setCategory(.playback, mode: .moviePlayback)
|
try? session.setCategory(.playback, mode: .moviePlayback)
|
||||||
|
|||||||
@@ -5,32 +5,38 @@ import UIKit
|
|||||||
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
||||||
/// - 单击:播放 / 暂停
|
/// - 单击:播放 / 暂停
|
||||||
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
||||||
|
/// - 上划 / 下划:显示 / 隐藏字幕层(由 SwiftUI 横屏页接收)
|
||||||
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
||||||
struct PlayerSurfaceView: UIViewRepresentable {
|
struct PlayerSurfaceView: UIViewRepresentable {
|
||||||
let controller: PlayerController
|
let controller: PlayerController
|
||||||
|
var onVerticalSwipe: ((Bool) -> Void)?
|
||||||
|
|
||||||
func makeUIView(context: Context) -> VideoSurfaceView {
|
func makeUIView(context: Context) -> VideoSurfaceView {
|
||||||
let view = VideoSurfaceView()
|
let view = VideoSurfaceView()
|
||||||
view.playerLayer.player = controller.player
|
|
||||||
view.controller = controller
|
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
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
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 {
|
final class VideoSurfaceView: UIView {
|
||||||
weak var controller: PlayerController?
|
weak var controller: PlayerController?
|
||||||
|
var onVerticalSwipe: ((Bool) -> Void)?
|
||||||
|
|
||||||
override class var layerClass: AnyClass {
|
private let videoLayer = AVPlayerLayer()
|
||||||
AVPlayerLayer.self
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerLayer: AVPlayerLayer {
|
var playerLayer: AVPlayerLayer { videoLayer }
|
||||||
layer as! AVPlayerLayer
|
|
||||||
}
|
|
||||||
|
|
||||||
private let minSwipeDistance: CGFloat = 48
|
private let minSwipeDistance: CGFloat = 48
|
||||||
private let minSwipeVelocity: CGFloat = 160
|
private let minSwipeVelocity: CGFloat = 160
|
||||||
@@ -39,11 +45,22 @@ final class VideoSurfaceView: UIView {
|
|||||||
private var tapRecognizer: UITapGestureRecognizer!
|
private var tapRecognizer: UITapGestureRecognizer!
|
||||||
private var panRecognizer: UIPanGestureRecognizer!
|
private var panRecognizer: UIPanGestureRecognizer!
|
||||||
private var longPressRecognizer: UILongPressGestureRecognizer!
|
private var longPressRecognizer: UILongPressGestureRecognizer!
|
||||||
|
private var didDispatchSwipe = false
|
||||||
|
private var swipeAxis: SwipeAxis?
|
||||||
|
|
||||||
|
private enum SwipeAxis {
|
||||||
|
case horizontal
|
||||||
|
case vertical
|
||||||
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = .black
|
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))
|
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||||
@@ -70,24 +87,87 @@ final class VideoSurfaceView: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
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() {
|
@objc private func handleTap() {
|
||||||
controller?.handleGesture(.singleTap)
|
controller?.handleGesture(.singleTap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||||
guard recognizer.state == .ended || recognizer.state == .cancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let translation = recognizer.translation(in: self)
|
let translation = recognizer.translation(in: self)
|
||||||
let velocity = recognizer.velocity(in: self)
|
let velocity = recognizer.velocity(in: self)
|
||||||
let dx = translation.x
|
let dx = translation.x
|
||||||
let dy = translation.y
|
let dy = translation.y
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
guard abs(dx) >= abs(dy),
|
||||||
abs(dx) >= minSwipeDistance,
|
abs(dx) >= minSwipeDistance,
|
||||||
abs(velocity.x) >= minSwipeVelocity else {
|
abs(velocity.x) >= minSwipeVelocity else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
didDispatchSwipe = true
|
||||||
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSwipeTracking() {
|
||||||
|
didDispatchSwipe = false
|
||||||
|
swipeAxis = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
<string>需要麦克风权限才能进行录音评测</string>
|
<string>需要麦克风权限才能进行录音评测</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
<key>UIRequiresFullScreen</key>
|
||||||
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,17 @@ enum HTTPClient {
|
|||||||
_ type: T.Type,
|
_ type: T.Type,
|
||||||
baseURL: String,
|
baseURL: String,
|
||||||
path: String,
|
path: String,
|
||||||
timeout: TimeInterval
|
timeout: TimeInterval,
|
||||||
|
additionalHeaders: [String: String] = [:]
|
||||||
) async throws -> (value: T, effectiveBaseURL: String) {
|
) async throws -> (value: T, effectiveBaseURL: String) {
|
||||||
|
var headers = ["Accept": "application/json"]
|
||||||
|
headers.merge(additionalHeaders) { _, new in new }
|
||||||
let result = try await execute(
|
let result = try await execute(
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
path: path,
|
path: path,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
headers: ["Accept": "application/json"]
|
headers: headers
|
||||||
)
|
)
|
||||||
guard (200...299).contains(result.statusCode) else {
|
guard (200...299).contains(result.statusCode) else {
|
||||||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
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)
|
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
|
||||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
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(
|
let result = try await HTTPClient.execute(
|
||||||
baseURL: AppConfig.serverBaseURL,
|
baseURL: AppConfig.serverBaseURL,
|
||||||
path: path,
|
path: path,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeout: AppConfig.assessmentTimeout,
|
timeout: AppConfig.assessmentTimeout,
|
||||||
headers: [
|
headers: headers,
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
|
||||||
"X-Client-Key": AppConfig.assessmentAPIKey,
|
|
||||||
],
|
|
||||||
body: body
|
body: body
|
||||||
)
|
)
|
||||||
guard (200...299).contains(result.statusCode) else {
|
guard (200...299).contains(result.statusCode) else {
|
||||||
@@ -171,15 +175,19 @@ enum ImitationAssessor {
|
|||||||
}
|
}
|
||||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
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(
|
let result = try await HTTPClient.execute(
|
||||||
baseURL: AppConfig.serverBaseURL,
|
baseURL: AppConfig.serverBaseURL,
|
||||||
path: "api/v1/dub-shares",
|
path: "api/v1/dub-shares",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeout: 120,
|
timeout: 120,
|
||||||
headers: [
|
headers: headers,
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
|
||||||
],
|
|
||||||
body: body
|
body: body
|
||||||
)
|
)
|
||||||
guard (200...299).contains(result.statusCode) else {
|
guard (200...299).contains(result.statusCode) else {
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ enum SentenceBoundaryAPI {
|
|||||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||||
throw SentenceBoundaryAPIError.invalidHash
|
throw SentenceBoundaryAPIError.invalidHash
|
||||||
}
|
}
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
if !AppConfig.authToken.isEmpty {
|
||||||
|
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||||
|
}
|
||||||
let result = try await HTTPClient.getJSON(
|
let result = try await HTTPClient.getJSON(
|
||||||
SentenceBoundaryDocument.self,
|
SentenceBoundaryDocument.self,
|
||||||
baseURL: AppConfig.serverBaseURL,
|
baseURL: AppConfig.serverBaseURL,
|
||||||
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
||||||
timeout: AppConfig.readTimeout
|
timeout: AppConfig.readTimeout,
|
||||||
|
additionalHeaders: headers
|
||||||
)
|
)
|
||||||
guard result.value.videoHash.lowercased() == hash else {
|
guard result.value.videoHash.lowercased() == hash else {
|
||||||
throw SentenceBoundaryAPIError.hashMismatch
|
throw SentenceBoundaryAPIError.hashMismatch
|
||||||
@@ -32,3 +37,186 @@ enum SentenceBoundaryAPI {
|
|||||||
return result.value
|
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 baseURL = AppConfig.serverBaseURL
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
private var boundaryDocuments: [String: SentenceBoundaryDocument] = [:]
|
||||||
|
|
||||||
func loadIfNeeded() async {
|
func loadIfNeeded() async {
|
||||||
guard !hasLoaded else {
|
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() 一致:先以无句边界装载,再拉取句边界后重新装载。
|
/// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。
|
||||||
func select(_ video: TrainingVideoSummary) {
|
func select(_ video: TrainingVideoSummary) {
|
||||||
guard let controller = controller else {
|
guard let controller = controller else {
|
||||||
@@ -41,6 +78,23 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
controller.boundaryState = .failed(message: "无法解析视频流地址")
|
controller.boundaryState = .failed(message: "无法解析视频流地址")
|
||||||
return
|
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)
|
let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url)
|
||||||
controller.load(loadingItem)
|
controller.load(loadingItem)
|
||||||
controller.boundaryState = .loading(isLocal: false)
|
controller.boundaryState = .loading(isLocal: false)
|
||||||
@@ -51,13 +105,12 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
guard activeVideoHash == video.videoHash else {
|
guard activeVideoHash == video.videoHash else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let item = TrainingMediaItem(
|
boundaryDocuments[document.videoHash] = document
|
||||||
id: video.videoHash,
|
SentenceExplanationStore.shared.sync(
|
||||||
title: video.title,
|
mediaId: document.videoHash,
|
||||||
url: url,
|
|
||||||
sentences: document.sentences
|
sentences: document.sentences
|
||||||
)
|
)
|
||||||
controller.load(item)
|
controller.updateBoundaries(document.sentences)
|
||||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||||
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -101,14 +154,11 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
guard activeVideoHash == hash else {
|
guard activeVideoHash == hash else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let loadedItem = TrainingMediaItem(
|
SentenceExplanationStore.shared.sync(
|
||||||
id: hash,
|
mediaId: document.videoHash,
|
||||||
title: fileName,
|
sentences: document.sentences
|
||||||
url: tempURL,
|
|
||||||
sentences: document.sentences,
|
|
||||||
isLocalFile: true
|
|
||||||
)
|
)
|
||||||
controller.load(loadedItem)
|
controller.updateBoundaries(document.sentences)
|
||||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||||
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
|
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -3,27 +3,107 @@ import SwiftUI
|
|||||||
/// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。
|
/// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。
|
||||||
struct FullscreenPlayerView: View {
|
struct FullscreenPlayerView: View {
|
||||||
@ObservedObject var controller: PlayerController
|
@ObservedObject var controller: PlayerController
|
||||||
|
var showsLessonTitle: Bool = true
|
||||||
|
var showChineseSubtitles: Bool = false
|
||||||
|
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||||
let onExit: () -> Void
|
let onExit: () -> Void
|
||||||
|
|
||||||
|
@State private var subtitleVisible = true
|
||||||
|
@State private var explanationSentence: SentenceBoundary?
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
PlayerSurfaceView(controller: controller)
|
PlayerSurfaceView(controller: controller) { isSwipeUp in
|
||||||
|
guard subtitleVisible != isSwipeUp else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) {
|
||||||
|
subtitleVisible = isSwipeUp
|
||||||
|
}
|
||||||
|
}
|
||||||
.ignoresSafeArea()
|
.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 {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if subtitleVisible {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if showsLessonTitle {
|
||||||
Text(controller.currentItem?.title ?? "未选择课程")
|
Text(controller.currentItem?.title ?? "未选择课程")
|
||||||
.font(.system(size: 17, weight: .bold))
|
.font(.system(size: 17, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
}
|
||||||
Text(sentenceMetaText)
|
Text(sentenceMetaText)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Theme.textOverlay)
|
.foregroundColor(Theme.textOverlay)
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(sentenceTextText)
|
Text(sentenceTextText)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(2)
|
.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
|
progressRow
|
||||||
Text(statusText)
|
Text(statusText)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
@@ -31,7 +111,9 @@ struct FullscreenPlayerView: View {
|
|||||||
}
|
}
|
||||||
.padding(18)
|
.padding(18)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Theme.overlay)
|
.background(Theme.overlayFaint)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -55,6 +137,11 @@ struct FullscreenPlayerView: View {
|
|||||||
@State private var progressDragging = false
|
@State private var progressDragging = false
|
||||||
@State private var dragPositionMs: Int64 = 0
|
@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 {
|
private var progressRow: some View {
|
||||||
let duration = controller.snapshot.durationMs
|
let duration = controller.snapshot.durationMs
|
||||||
return HStack(spacing: 8) {
|
return HStack(spacing: 8) {
|
||||||
@@ -115,10 +202,10 @@ struct FullscreenPlayerView: View {
|
|||||||
case .loading(let isLocal):
|
case .loading(let isLocal):
|
||||||
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
|
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
|
||||||
case .failed:
|
case .failed:
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
case .idle, .loaded:
|
case .idle, .loaded:
|
||||||
guard let sentence = controller.currentSentence else {
|
guard let sentence = controller.currentSentence else {
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
}
|
}
|
||||||
if let text = sentence.text, !text.isEmpty {
|
if let text = sentence.text, !text.isEmpty {
|
||||||
return text
|
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 {
|
private var statusText: String {
|
||||||
|
if let message = controller.cacheState.statusMessage {
|
||||||
|
return message
|
||||||
|
}
|
||||||
if let message = controller.transientMessage {
|
if let message = controller.transientMessage {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
return controller.playbackStatusText(controller.snapshot)
|
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
|
var showsContinuousToggle: Bool
|
||||||
@Binding var continuousPlayback: Bool
|
@Binding var continuousPlayback: Bool
|
||||||
var showsSubtitle: Binding<Bool>? = .constant(true)
|
var showsSubtitle: Binding<Bool>? = .constant(true)
|
||||||
|
var showsLessonTitle: Bool = true
|
||||||
|
var showChineseSubtitles: Binding<Bool>? = nil
|
||||||
|
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||||
var onFullscreen: () -> Void
|
var onFullscreen: () -> Void
|
||||||
|
|
||||||
@State private var progressDragging = false
|
@State private var progressDragging = false
|
||||||
@State private var dragPositionMs: Int64 = 0
|
@State private var dragPositionMs: Int64 = 0
|
||||||
|
@State private var explanationSentence: SentenceBoundary?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -19,6 +23,14 @@ struct PlayerSectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.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 {
|
private var playerSurface: some View {
|
||||||
@@ -66,10 +78,12 @@ struct PlayerSectionView: View {
|
|||||||
|
|
||||||
private var sentencePanel: some View {
|
private var sentencePanel: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if showsLessonTitle {
|
||||||
Text(controller.currentItem?.title ?? "未选择课程")
|
Text(controller.currentItem?.title ?? "未选择课程")
|
||||||
.font(.system(size: 17, weight: .bold))
|
.font(.system(size: 17, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
}
|
||||||
if showsContinuousToggle {
|
if showsContinuousToggle {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("连续播放")
|
Text("连续播放")
|
||||||
@@ -82,13 +96,56 @@ struct PlayerSectionView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.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)
|
Text(sentenceMetaText)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Theme.textSubtle)
|
.foregroundColor(Theme.textSubtle)
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(sentenceTextText)
|
Text(sentenceTextText)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(2)
|
.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
|
progressRow
|
||||||
Text(statusText)
|
Text(statusText)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
@@ -159,10 +216,10 @@ struct PlayerSectionView: View {
|
|||||||
}
|
}
|
||||||
return controller.currentItem?.title ?? "加载中"
|
return controller.currentItem?.title ?? "加载中"
|
||||||
case .failed:
|
case .failed:
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
case .idle, .loaded:
|
case .idle, .loaded:
|
||||||
guard let sentence = controller.currentSentence else {
|
guard let sentence = controller.currentSentence else {
|
||||||
return controller.currentItem?.title ?? "未选择课程"
|
return ""
|
||||||
}
|
}
|
||||||
if let text = sentence.text, !text.isEmpty {
|
if let text = sentence.text, !text.isEmpty {
|
||||||
return text
|
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 {
|
private var statusText: String {
|
||||||
|
if let message = controller.cacheState.statusMessage {
|
||||||
|
return message
|
||||||
|
}
|
||||||
if let message = controller.transientMessage {
|
if let message = controller.transientMessage {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
@@ -195,3 +269,144 @@ struct PlayerSectionView: View {
|
|||||||
controller.setPlaybackSpeed(next)
|
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
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
|
@StateObject private var auth = AuthManager()
|
||||||
@StateObject private var controller = PlayerController()
|
@StateObject private var controller = PlayerController()
|
||||||
@StateObject private var catalogVM = CatalogViewModel()
|
@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 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 showImporter = false
|
||||||
@State private var showFullscreenPlayer = 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 {
|
enum Module {
|
||||||
|
case course
|
||||||
case train
|
case train
|
||||||
case test
|
case test
|
||||||
|
case mine
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if auth.isLoggedIn {
|
||||||
|
mainContent
|
||||||
|
} else {
|
||||||
|
AuthView(auth: auth) {
|
||||||
|
Task { await loadCourses() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.background.ignoresSafeArea()
|
Theme.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
moduleTabs
|
moduleTabs
|
||||||
if activeModule == .train {
|
switch activeModule {
|
||||||
|
case .course:
|
||||||
|
coursePage
|
||||||
|
case .train:
|
||||||
TrainingView(
|
TrainingView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
catalogVM: catalogVM,
|
catalogVM: catalogVM,
|
||||||
continuousPlayback: $continuousPlayback,
|
continuousPlayback: $continuousPlayback,
|
||||||
|
showChineseSubtitles: $showChineseSubtitles,
|
||||||
|
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||||||
onFullscreen: enterFullscreen
|
onFullscreen: enterFullscreen
|
||||||
)
|
)
|
||||||
} else {
|
case .test:
|
||||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
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 {
|
.onAppear {
|
||||||
catalogVM.controller = controller
|
catalogVM.controller = controller
|
||||||
controller.setContinuousPlayback(continuousPlayback)
|
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
|
.onChange(of: activeModule) { module in
|
||||||
if module == .train {
|
if module == .train {
|
||||||
controller.setContinuousPlayback(continuousPlayback)
|
controller.setContinuousPlayback(continuousPlayback)
|
||||||
|
ensureChineseTranslations()
|
||||||
|
} else if module == .test {
|
||||||
|
// 与安卓测试页一致:按当前句窗口播放,避免滑进下一句。
|
||||||
|
controller.setContinuousPlayback(false)
|
||||||
} else {
|
} else {
|
||||||
controller.setContinuousPlayback(true)
|
controller.setContinuousPlayback(true)
|
||||||
}
|
}
|
||||||
@@ -56,11 +130,21 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onReceive(catalogVM.$activeVideoHash) { videoHash in
|
||||||
|
syncCurrentCourse(with: videoHash)
|
||||||
|
}
|
||||||
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
||||||
FullscreenPlayerView(controller: controller) {
|
FullscreenPlayerView(
|
||||||
|
controller: controller,
|
||||||
|
showsLessonTitle: activeModule != .train,
|
||||||
|
showChineseSubtitles: showChineseSubtitles,
|
||||||
|
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder
|
||||||
|
) {
|
||||||
exitFullscreen()
|
exitFullscreen()
|
||||||
}
|
}
|
||||||
.onAppear(perform: enterFullscreen)
|
.onAppear {
|
||||||
|
AppDelegate.lockOrientation(.landscape)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,18 +154,24 @@ struct RootView: View {
|
|||||||
Text("八哥口语")
|
Text("八哥口语")
|
||||||
.font(.system(size: 32, weight: .bold))
|
.font(.system(size: 32, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Text("外语跟读训练神器")
|
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundColor(Theme.textMuted)
|
.foregroundColor(Theme.textMuted)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if activeModule == .train {
|
||||||
headerButton("导入") {
|
headerButton("导入") {
|
||||||
showImporter = true
|
showImporter = true
|
||||||
}
|
}
|
||||||
headerButton("刷新") {
|
|
||||||
Task {
|
|
||||||
await catalogVM.load()
|
|
||||||
}
|
}
|
||||||
|
headerButton("刷新") {
|
||||||
|
Task { await loadCourses() }
|
||||||
|
}
|
||||||
|
headerButton("退出") {
|
||||||
|
auth.logout()
|
||||||
|
currentCourse = nil
|
||||||
|
courses = []
|
||||||
|
activeModule = .course
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -105,50 +195,495 @@ struct RootView: View {
|
|||||||
|
|
||||||
private var moduleTabs: some View {
|
private var moduleTabs: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
moduleTab("课程", module: .course)
|
||||||
moduleTab("训练", module: .train)
|
moduleTab("训练", module: .train)
|
||||||
moduleTab("测试", module: .test)
|
moduleTab("测试", module: .test)
|
||||||
|
moduleTab("我的", module: .mine)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||||
let selected = activeModule == module
|
Button {
|
||||||
return Button {
|
if module != .course && currentCourse == nil {
|
||||||
|
showCourseGuardToast = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
showCourseGuardToast = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
activeModule = module
|
activeModule = module
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 38)
|
.frame(height: 38)
|
||||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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() {
|
private func enterFullscreen() {
|
||||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
AppDelegate.lockOrientation(.landscape)
|
||||||
updateSupportedInterfaceOrientations()
|
|
||||||
showFullscreenPlayer = true
|
showFullscreenPlayer = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func exitFullscreen() {
|
private func exitFullscreen() {
|
||||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
let restored: UIInterfaceOrientationMask =
|
||||||
updateSupportedInterfaceOrientations()
|
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||||||
|
AppDelegate.lockOrientation(restored)
|
||||||
showFullscreenPlayer = false
|
showFullscreenPlayer = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSupportedInterfaceOrientations() {
|
private var chineseSubtitlePlaceholder: String {
|
||||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
if chineseTranslationLoading {
|
||||||
let controllers = scenes.flatMap { $0.windows }.compactMap(\.rootViewController)
|
return "正在翻译中文字幕…"
|
||||||
for controller in controllers {
|
}
|
||||||
if #available(iOS 16.0, *) {
|
if !chineseTranslationError.isEmpty {
|
||||||
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
|
return chineseTranslationError
|
||||||
} else {
|
}
|
||||||
UIViewController.attemptRotationToDeviceOrientation()
|
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,6 +3,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TestView: View {
|
struct TestView: View {
|
||||||
@ObservedObject var controller: PlayerController
|
@ObservedObject var controller: PlayerController
|
||||||
|
@ObservedObject var historyManager: TrainingHistoryManager
|
||||||
let onFullscreen: () -> Void
|
let onFullscreen: () -> Void
|
||||||
|
|
||||||
@State private var recordingSession: AudioRecorderSession?
|
@State private var recordingSession: AudioRecorderSession?
|
||||||
@@ -25,6 +26,8 @@ struct TestView: View {
|
|||||||
@State private var showShareSheet = false
|
@State private var showShareSheet = false
|
||||||
@State private var dubPlaybackObserver: NSObjectProtocol?
|
@State private var dubPlaybackObserver: NSObjectProtocol?
|
||||||
@State private var testSubtitleVisible = true
|
@State private var testSubtitleVisible = true
|
||||||
|
@State private var loadedCourseHash: String?
|
||||||
|
@State private var remoteHistoryGeneration = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -32,7 +35,7 @@ struct TestView: View {
|
|||||||
PlayerSectionView(
|
PlayerSectionView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
showsContinuousToggle: false,
|
showsContinuousToggle: false,
|
||||||
continuousPlayback: .constant(true),
|
continuousPlayback: .constant(false),
|
||||||
showsSubtitle: $testSubtitleVisible,
|
showsSubtitle: $testSubtitleVisible,
|
||||||
onFullscreen: onFullscreen
|
onFullscreen: onFullscreen
|
||||||
)
|
)
|
||||||
@@ -58,13 +61,16 @@ struct TestView: View {
|
|||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
sentenceButton("上一句") {
|
sentenceButton("上一句") {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
controller.seekToPreviousSentence()
|
controller.seekToPreviousSentence()
|
||||||
}
|
}
|
||||||
sentenceButton("播放原句") {
|
sentenceButton("播放原句") {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
controller.playCurrentSentenceAndStop()
|
controller.playCurrentSentenceAndStop()
|
||||||
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
|
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
|
||||||
}
|
}
|
||||||
sentenceButton("下一句") {
|
sentenceButton("下一句") {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
controller.seekToNextSentence()
|
controller.seekToNextSentence()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,19 +86,24 @@ struct TestView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(isAssessing)
|
.disabled(isAssessing)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
dubButton(
|
||||||
|
dubbingPlayback ? "停止播放" : "播放本句录音",
|
||||||
|
enabled: dubbingPlayback || (controller.currentSentence.flatMap { dubSegments[$0.index] } != nil)
|
||||||
|
) {
|
||||||
toggleDubPlayback()
|
toggleDubPlayback()
|
||||||
}
|
}
|
||||||
dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
generateDubShareAudio()
|
generateDubShareAudio()
|
||||||
}
|
}
|
||||||
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
|
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
shareMergedDub()
|
shareMergedDub()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(dubbingStatus)
|
Text(dubbingStatus)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Theme.accentDeep)
|
.foregroundColor(isSharingDub ? Theme.danger : Theme.accentDeep)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text(scoreSummary)
|
Text(scoreSummary)
|
||||||
.font(.system(size: 22, weight: .bold))
|
.font(.system(size: 22, weight: .bold))
|
||||||
@@ -113,7 +124,8 @@ struct TestView: View {
|
|||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(controller.$currentItem) { _ in
|
.onReceive(controller.$currentItem) { item in
|
||||||
|
synchronizeHistory(for: item?.id)
|
||||||
refreshTestUI()
|
refreshTestUI()
|
||||||
}
|
}
|
||||||
.onReceive(controller.$currentSentence) { _ in
|
.onReceive(controller.$currentSentence) { _ in
|
||||||
@@ -122,6 +134,14 @@ struct TestView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
stopDubPlayback(message: nil)
|
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) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
if let url = shareURL {
|
if let url = shareURL {
|
||||||
ActivityShareSheet(items: [url])
|
ActivityShareSheet(items: [url])
|
||||||
@@ -159,6 +179,7 @@ struct TestView: View {
|
|||||||
// MARK: - 录音
|
// MARK: - 录音
|
||||||
|
|
||||||
private func toggleRecording() {
|
private func toggleRecording() {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
if isRecording {
|
if isRecording {
|
||||||
stopRecordingAndAssess()
|
stopRecordingAndAssess()
|
||||||
} else {
|
} else {
|
||||||
@@ -281,16 +302,26 @@ struct TestView: View {
|
|||||||
try? FileManager.default.removeItem(at: rawURL)
|
try? FileManager.default.removeItem(at: rawURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let oldURL = dubSegments[sentence.index] {
|
var audioURL = url
|
||||||
try? FileManager.default.removeItem(at: oldURL)
|
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)"
|
||||||
}
|
}
|
||||||
if let oldRawURL = dubRawSegments[sentence.index] {
|
|
||||||
try? FileManager.default.removeItem(at: oldRawURL)
|
|
||||||
}
|
}
|
||||||
dubSegments[sentence.index] = url
|
dubSegments[sentence.index] = audioURL
|
||||||
dubRawSegments[sentence.index] = rawURL
|
dubRawSegments[sentence.index] = rawPersistedURL
|
||||||
mergedDubURL = nil
|
mergedDubURL = nil
|
||||||
submitAssessment(recordingURL: url)
|
submitAssessment(recordingURL: audioURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restorePlaybackAudioSession() {
|
private func restorePlaybackAudioSession() {
|
||||||
@@ -328,6 +359,8 @@ struct TestView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await ImitationAssessor.assess(request: request)
|
let result = try await ImitationAssessor.assess(request: request)
|
||||||
|
latestScores[sentence.index] = result
|
||||||
|
historyManager.store.saveScores(videoHash: item.id, scores: latestScores)
|
||||||
applyAssessmentResult(result)
|
applyAssessmentResult(result)
|
||||||
} catch {
|
} catch {
|
||||||
testStatus = "评测失败:\(error.localizedDescription)"
|
testStatus = "评测失败:\(error.localizedDescription)"
|
||||||
@@ -382,44 +415,197 @@ struct TestView: View {
|
|||||||
testStatus = "请先在训练模块选择一个云端课程"
|
testStatus = "请先在训练模块选择一个云端课程"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let score = latestScores[sentence.index] {
|
||||||
|
applyAssessmentResult(score)
|
||||||
|
} else {
|
||||||
let currentRecording = dubSegments[sentence.index]
|
let currentRecording = dubSegments[sentence.index]
|
||||||
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
||||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
||||||
if mergedDubURL == nil {
|
scoreSummary = "尚未评测"
|
||||||
|
scoreDetail = ""
|
||||||
|
}
|
||||||
|
if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub {
|
||||||
dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音"
|
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: - 配音
|
// MARK: - 配音
|
||||||
|
|
||||||
private func toggleDubPlayback() {
|
private func toggleDubPlayback() {
|
||||||
if dubbingPlayback {
|
if dubbingPlayback {
|
||||||
stopDubPlayback(message: "已停止配音播放")
|
stopDubPlayback(message: "已停止录音播放")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let sentence = controller.currentSentence
|
guard let sentence = controller.currentSentence else {
|
||||||
let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] }
|
dubbingStatus = "请先选择要播放的句子"
|
||||||
guard let audioURL else { return }
|
return
|
||||||
if mergedDubURL == nil, let sentence = sentence {
|
|
||||||
controller.seek(to: sentence.startMs)
|
|
||||||
}
|
}
|
||||||
|
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.pause()
|
||||||
controller.setVolume(0)
|
controller.setVolume(0)
|
||||||
let player = dubPlayer ?? AVPlayer()
|
let player = dubPlayer ?? AVPlayer()
|
||||||
dubPlayer = player
|
dubPlayer = player
|
||||||
player.replaceCurrentItem(with: AVPlayerItem(url: audioURL))
|
let item = AVPlayerItem(url: audioURL)
|
||||||
|
player.replaceCurrentItem(with: item)
|
||||||
dubbingPlayback = true
|
dubbingPlayback = true
|
||||||
dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音"
|
dubbingStatus = "正在播放当前句录音"
|
||||||
player.play()
|
player.play()
|
||||||
if dubPlaybackObserver == nil {
|
if let dubPlaybackObserver {
|
||||||
|
NotificationCenter.default.removeObserver(dubPlaybackObserver)
|
||||||
|
self.dubPlaybackObserver = nil
|
||||||
|
}
|
||||||
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
||||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||||
object: player.currentItem,
|
object: item,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { _ in
|
) { _ in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||||
stopDubPlayback(message: "配音播放完成")
|
stopDubPlayback(message: "本句录音播放完成")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,7 +654,7 @@ struct TestView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isSharingDub = true
|
isSharingDub = true
|
||||||
dubbingStatus = "正在生成分享链接…"
|
dubbingStatus = "正在生成分享链接,请稍候..."
|
||||||
let request = DubShareUploadRequest(
|
let request = DubShareUploadRequest(
|
||||||
videoHash: item.id,
|
videoHash: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ enum Theme {
|
|||||||
static let progressTrackDark = Color(hex: 0x373E46)
|
static let progressTrackDark = Color(hex: 0x373E46)
|
||||||
static let lightBorder = Color(hex: 0xDAE2E8)
|
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 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 textDark = Color(hex: 0x12181F)
|
||||||
static let textMuted = Color(hex: 0x97A2AE)
|
static let textMuted = Color(hex: 0x97A2AE)
|
||||||
static let textSubtle = Color(hex: 0x58626C)
|
static let textSubtle = Color(hex: 0x58626C)
|
||||||
|
static let danger = Color(hex: 0xDC2626)
|
||||||
static let textOverlay = Color(hex: 0xA3ACB6)
|
static let textOverlay = Color(hex: 0xA3ACB6)
|
||||||
|
|
||||||
static func formatTime(_ ms: Int64) -> String {
|
static func formatTime(_ ms: Int64) -> String {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ struct TrainingView: View {
|
|||||||
@ObservedObject var controller: PlayerController
|
@ObservedObject var controller: PlayerController
|
||||||
@ObservedObject var catalogVM: CatalogViewModel
|
@ObservedObject var catalogVM: CatalogViewModel
|
||||||
@Binding var continuousPlayback: Bool
|
@Binding var continuousPlayback: Bool
|
||||||
|
@Binding var showChineseSubtitles: Bool
|
||||||
|
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
|
||||||
let onFullscreen: () -> Void
|
let onFullscreen: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -13,6 +15,9 @@ struct TrainingView: View {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
showsContinuousToggle: true,
|
showsContinuousToggle: true,
|
||||||
continuousPlayback: $continuousPlayback,
|
continuousPlayback: $continuousPlayback,
|
||||||
|
showsLessonTitle: false,
|
||||||
|
showChineseSubtitles: $showChineseSubtitles,
|
||||||
|
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||||||
onFullscreen: onFullscreen
|
onFullscreen: onFullscreen
|
||||||
)
|
)
|
||||||
CatalogView(vm: catalogVM)
|
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 功能对齐的移动端:
|
iOS 端与 [android/](../android/README.md) 示例 App 功能对齐的移动端:
|
||||||
|
|
||||||
- 在线视频目录(`GET /api/v1/videos`,仅展示 `ready` 课程,自动解析相对流地址)
|
- 在线视频目录(`GET /api/v1/videos`,仅展示 `ready` 课程,自动解析相对流地址)
|
||||||
- AVPlayer 在线播放 + 本地缓存导入;单击播放/暂停
|
- 云端视频先完整下载为本地整文件缓存,下载完成后才允许 AVPlayer 播放;
|
||||||
|
支持本地缓存导入;单击播放/暂停
|
||||||
- 左划 / 右划:上一句 / 下一句(无句边界时回退为 10 秒快退 / 快进)
|
- 左划 / 右划:上一句 / 下一句(无句边界时回退为 10 秒快退 / 快进)
|
||||||
- 长按播放区:按触摸点水平位置变速(左慢右快,0.5x - 2.0x),松手恢复原速
|
- 长按播放区:按触摸点水平位置变速(左慢右快,0.5x - 2.0x),松手恢复原速
|
||||||
- 按句跳转:句边界来自 `GET /api/v1/videos/{sha256}/sentence-boundaries`
|
- 按句跳转:句边界来自 `GET /api/v1/videos/{sha256}/sentence-boundaries`
|
||||||
|
|||||||
2339
media_player.py
2339
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
|
av>=12.0
|
||||||
|
requests>=2.32.0
|
||||||
sounddevice>=0.4.6
|
sounddevice>=0.4.6
|
||||||
numpy>=1.26
|
numpy>=1.26
|
||||||
Pillow>=10.0
|
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
|
MAX_ATTEMPT_UPLOAD_BYTES=52428800
|
||||||
KEEP_ATTEMPT_AUDIO=false
|
KEEP_ATTEMPT_AUDIO=false
|
||||||
ASSESSMENT_PASS_SCORE=70
|
ASSESSMENT_PASS_SCORE=70
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
||||||
MOSS_MODEL=OpenMOSS-Team/MOSS-Transcribe-Diarize
|
MOSS_MODEL=OpenMOSS-Team/MOSS-Transcribe-Diarize
|
||||||
@@ -16,3 +17,23 @@ MOSS_TIMEOUT_SECONDS=1800
|
|||||||
MOSS_MAX_NEW_TOKENS=65536
|
MOSS_MAX_NEW_TOKENS=65536
|
||||||
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
|
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
|
||||||
MOSS_END_PADDING_MS=300
|
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 视频播放和朗读评分。
|
- `oral-trainer-api`:视频上传、管理后台、边界查询、Range 视频播放和朗读评分。
|
||||||
- GPU 转写服务(二选一,只在服务器内网监听):
|
- GPU 转写服务(二选一,只在服务器内网监听):
|
||||||
- `whisper/`:faster-whisper(Speaches),推荐,部署最简单,见第 2A 节。
|
- `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 以上显存开始验证。
|
推荐 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
|
cd /opt/whisper
|
||||||
|
|
||||||
cp .env.example .env # 按需修改 WHISPER_MODEL
|
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 up -d
|
||||||
docker compose logs -f whisper # 首次启动下载模型(约 3 GB)
|
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 累积,
|
`sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积,
|
||||||
只有碰到句号(`.`)或问号(`?`)才算一句结束;Whisper 在没有标点处断开的
|
只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束;
|
||||||
segment 会自动合并成同一句。若转写服务支持词级时间戳
|
逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有
|
||||||
|
标点处断开的 segment 会自动合并成同一句。管理后台的文本保存、换行拆分占位、相邻句
|
||||||
|
合并和结束时间微调会立即入库,但不会触发翻译或转写;关闭句子对话框时才统一执行
|
||||||
|
一轮拆分时间戳处理和翻译。拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按
|
||||||
|
文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新,
|
||||||
|
并保证相邻句子不重叠。若转写服务支持词级时间戳
|
||||||
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
|
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
|
||||||
标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为
|
标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为
|
||||||
不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。
|
不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。
|
||||||
@@ -181,12 +189,42 @@ openssl rand -hex 32
|
|||||||
```dotenv
|
```dotenv
|
||||||
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
|
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
|
||||||
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
|
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` 改为
|
若使用 Whisper 方案(第 2A 节),把 `MOSS_TRANSCRIBE_URL` 改为
|
||||||
`http://127.0.0.1:9000/v1/audio/transcriptions`,并把 `MOSS_MODEL` 设为
|
`http://127.0.0.1:9000/v1/audio/transcriptions`,并把 `MOSS_MODEL` 设为
|
||||||
`Systran/faster-whisper-large-v3`。
|
`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
|
```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 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=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 \
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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;
|
- chunked video upload with SHA-256 calculation;
|
||||||
- files stored under `data/v/` and metadata stored in SQLite;
|
- 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;
|
- background sentence transcription through MOSS-Transcribe-Diarize;
|
||||||
- a browser-based administration page at `/admin`;
|
- a browser-based administration page at `/admin`;
|
||||||
- video catalog and HTTP Range playback;
|
- video catalog and HTTP Range playback;
|
||||||
- sentence-boundary lookup compatible with the existing Android SDK;
|
- sentence-boundary lookup compatible with the existing Android SDK;
|
||||||
- student recording upload and `asr-fluency-v1` assessment;
|
- 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.
|
- the legacy JSON boundary index as a read-only fallback.
|
||||||
|
|
||||||
## Local Run
|
## 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
|
When `MOSS_TRANSCRIBE_URL` is empty, uploaded videos still receive silence-based
|
||||||
boundaries, but no reference transcript is produced and assessment is disabled.
|
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
|
## Main Endpoints
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -41,18 +52,74 @@ GET /api/v1/videos
|
|||||||
GET /api/v1/videos/{sha256}
|
GET /api/v1/videos/{sha256}
|
||||||
GET /api/v1/videos/{sha256}/content
|
GET /api/v1/videos/{sha256}/content
|
||||||
GET /api/v1/videos/{sha256}/sentence-boundaries
|
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/videos/{sha256}/sentences/{index}/assessments
|
||||||
|
|
||||||
POST /api/v1/admin/videos
|
POST /api/v1/admin/videos
|
||||||
PUT /api/v1/admin/videos/raw
|
PUT /api/v1/admin/videos/raw
|
||||||
POST /api/v1/admin/videos/{sha256}/process
|
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/{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}
|
DELETE /api/v1/admin/videos/{sha256}
|
||||||
```
|
```
|
||||||
|
|
||||||
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
|
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
|
||||||
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
|
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
|
## Assessment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ sudo systemctl restart whisper-transcribe
|
|||||||
| 现象 | 原因 | 处理 |
|
| 现象 | 原因 | 处理 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 更新后接口行为没变 | 忘了 `--build`,容器还在跑旧镜像 | 执行 `docker compose up -d --build` |
|
| 更新后接口行为没变 | 忘了 `--build`,容器还在跑旧镜像 | 执行 `docker compose up -d --build` |
|
||||||
| 构建时 pip 安装超时 | 服务器访问 PyPI 受限 | 给 Dockerfile 的 pip install 加 `-i https://pypi.tuna.tsinghua.edu.cn/simple` 后重新构建 |
|
| 构建时 pip 安装超时 | 服务器访问 PyPI 受限 | Dockerfile 默认已用清华 PyPI;仍慢可改 `PIP_INDEX_URL` 后 `--build` |
|
||||||
| 构建时拉基础镜像超时 | Docker Hub 网络问题 | 检查 `/etc/docker/daemon.json` 的 `registry-mirrors`(已配置 DaoCloud) |
|
| 构建时拉基础镜像超时 | 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` |
|
| `healthz` 里 `moss_configured: false` | `.env` 里 `MOSS_TRANSCRIBE_URL` 为空或改了未重建 | 改 `.env` 后执行 `docker compose up -d` |
|
||||||
| Whisper 9000 端口连不上 | 服务未运行或容器被删 | `systemctl status whisper-transcribe`;容器被删时用 `systemctl start` 重建 |
|
| Whisper 9000 端口连不上 | 服务未运行或容器被删 | `systemctl status whisper-transcribe`;容器被删时用 `systemctl start` 重建 |
|
||||||
| API 重启后视频卡在 `processing` | 重启中断了处理流程 | 后台点“重新处理”即可 |
|
| 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,
|
audio_path: Path,
|
||||||
language: Optional[str] = None,
|
language: Optional[str] = None,
|
||||||
retained_audio_filename: Optional[str] = None,
|
retained_audio_filename: Optional[str] = None,
|
||||||
|
user_id: str = "",
|
||||||
) -> AssessmentResult:
|
) -> AssessmentResult:
|
||||||
if not self.transcriber.available:
|
if not self.transcriber.available:
|
||||||
raise RuntimeError("MOSS transcription is not configured on this server.")
|
raise RuntimeError("MOSS transcription is not configured on this server.")
|
||||||
@@ -103,5 +104,6 @@ class AssessmentService:
|
|||||||
sentence_index=sentence_index,
|
sentence_index=sentence_index,
|
||||||
result=result.model_dump(mode="json"),
|
result=result.model_dump(mode="json"),
|
||||||
audio_filename=retained_audio_filename,
|
audio_filename=retained_audio_filename,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
return result
|
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
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
def _bool_env(name: str, default: bool) -> bool:
|
def _bool_env(name: str, default: bool) -> bool:
|
||||||
@@ -25,7 +26,20 @@ class Settings:
|
|||||||
moss_timeout_seconds: float
|
moss_timeout_seconds: float
|
||||||
moss_max_new_tokens: int
|
moss_max_new_tokens: int
|
||||||
moss_end_padding_ms: 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
|
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
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
@@ -53,7 +67,28 @@ class Settings:
|
|||||||
moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")),
|
moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")),
|
||||||
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
|
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
|
||||||
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
|
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")),
|
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
|
@property
|
||||||
@@ -76,6 +111,34 @@ class Settings:
|
|||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
return self.data_dir / "oral_trainer.sqlite3"
|
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:
|
def ensure_directories(self) -> None:
|
||||||
for path in (self.data_dir, self.videos_dir, self.work_dir, self.attempts_dir, self.dub_shares_dir):
|
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)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: sentence_api/Dockerfile
|
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
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
env_file:
|
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
@@ -12,6 +12,7 @@ class SentenceBoundary(BaseModel):
|
|||||||
end_ms: int = Field(gt=0)
|
end_ms: int = Field(gt=0)
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
|
zh_text: Optional[str] = None
|
||||||
reference_speech_duration_ms: Optional[int] = Field(default=None, gt=0)
|
reference_speech_duration_ms: Optional[int] = Field(default=None, gt=0)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -51,7 +52,7 @@ class SentenceBoundaryDocument(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
VideoStatus = Literal["uploaded", "processing", "ready", "failed"]
|
VideoStatus = Literal["uploaded", "processing", "ready", "failed", "local_subtitles"]
|
||||||
|
|
||||||
|
|
||||||
class VideoSummary(BaseModel):
|
class VideoSummary(BaseModel):
|
||||||
@@ -92,6 +93,147 @@ class SentenceTextUpdate(BaseModel):
|
|||||||
language: Optional[str] = Field(default=None, max_length=32)
|
language: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSentenceBatchItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
start_ms: int = Field(ge=0)
|
||||||
|
end_ms: int = Field(gt=0)
|
||||||
|
text: str = Field(min_length=1, max_length=4000)
|
||||||
|
language: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
zh_text: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
reference_speech_duration_ms: int = Field(gt=0)
|
||||||
|
source_start_ms: Optional[int] = Field(default=None, ge=0)
|
||||||
|
source_end_ms: Optional[int] = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_range(self):
|
||||||
|
if self.end_ms <= self.start_ms:
|
||||||
|
raise ValueError("end_ms must be greater than start_ms")
|
||||||
|
if (self.source_start_ms is None) != (self.source_end_ms is None):
|
||||||
|
raise ValueError("source_start_ms and source_end_ms must be set together")
|
||||||
|
elif self.source_start_ms is not None and self.source_end_ms <= self.source_start_ms:
|
||||||
|
raise ValueError("source end_ms must be greater than source start_ms")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSentenceBatchUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
sentences: List[AdminSentenceBatchItem] = Field(min_length=1, max_length=10000)
|
||||||
|
process_timings: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CourseTitleUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
title: str = Field(max_length=200)
|
||||||
|
|
||||||
|
@field_validator("title")
|
||||||
|
@classmethod
|
||||||
|
def validate_title(cls, value: str) -> str:
|
||||||
|
cleaned = value.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("title cannot be empty")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=50)
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceExplanationRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
sentence_text: str = Field(min_length=1, max_length=4000)
|
||||||
|
course_title: str = Field(default="", max_length=200)
|
||||||
|
sentence_index: Optional[int] = Field(default=None, ge=0)
|
||||||
|
language: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceExplanationResponse(BaseModel):
|
||||||
|
explanation: str = Field(min_length=1, max_length=6000)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleTranslationItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
index: int = Field(ge=0)
|
||||||
|
zh_text: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleTranslationResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
video_hash: str
|
||||||
|
translations: List[SubtitleTranslationItem]
|
||||||
|
complete: bool = True
|
||||||
|
status: str = "ready"
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSubtitleTranslationRequest(BaseModel):
|
||||||
|
"""Upload locally generated English subtitles so the server can translate them."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
duration_ms: int = Field(gt=0)
|
||||||
|
algorithm_version: str = Field(default="local-pc", min_length=1, max_length=64)
|
||||||
|
sentences: List[SentenceBoundary] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationTerm(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source: str = Field(min_length=1, max_length=120)
|
||||||
|
zh: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
@field_validator("source", "zh")
|
||||||
|
@classmethod
|
||||||
|
def strip_term(cls, value: str) -> str:
|
||||||
|
cleaned = value.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("translation term cannot be empty")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleRetranslateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
terms: List[TranslationTerm] = Field(default_factory=list, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSubtitleTranslationResponse(SubtitleTranslationResponse):
|
||||||
|
terms: List[TranslationTerm] = Field(default_factory=list)
|
||||||
|
missing_count: int = Field(default=0, ge=0)
|
||||||
|
attempted_count: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceBoundaryAdjust(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
delta_ms: int = Field(ge=-10_000, le=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceGapRepairRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
min_gap_ms: int = Field(default=8000, ge=2000, le=180_000)
|
||||||
|
|
||||||
|
|
||||||
|
class SentenceGapRepairResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
video_hash: str
|
||||||
|
min_gap_ms: int = Field(ge=1)
|
||||||
|
gap_count: int = Field(ge=0)
|
||||||
|
transcribed_gaps: int = Field(ge=0)
|
||||||
|
inserted_count: int = Field(ge=0)
|
||||||
|
sentence_count: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
class TextSubstitution(BaseModel):
|
class TextSubstitution(BaseModel):
|
||||||
expected: str
|
expected: str
|
||||||
actual: str
|
actual: str
|
||||||
@@ -125,3 +267,84 @@ class AssessmentResult(BaseModel):
|
|||||||
substitutions: List[TextSubstitution]
|
substitutions: List[TextSubstitution]
|
||||||
feedback: str
|
feedback: str
|
||||||
details: Dict[str, str] = Field(default_factory=dict)
|
details: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
nickname: Optional[str] = Field(default=None, max_length=80)
|
||||||
|
phone: Optional[str] = Field(default=None, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPublic(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
nickname: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserPublic
|
||||||
|
|
||||||
|
|
||||||
|
class SsoExchangeRequest(BaseModel):
|
||||||
|
access_token: str = Field(min_length=1, max_length=4096)
|
||||||
|
|
||||||
|
|
||||||
|
class SsoExchangeResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserPublic
|
||||||
|
phone_bound: bool = False
|
||||||
|
is_new_user: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SsoBindPhoneRequest(BaseModel):
|
||||||
|
phone: str = Field(min_length=1, max_length=20, pattern=r"^1[3-9]\d{9}$")
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
nickname: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
enrollment_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminListResponse(BaseModel):
|
||||||
|
users: List[UserAdminItem]
|
||||||
|
|
||||||
|
|
||||||
|
class CourseSummary(BaseModel):
|
||||||
|
video_hash: str
|
||||||
|
title: str
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
sentence_count: int = Field(ge=0)
|
||||||
|
stream_url: str
|
||||||
|
enrolled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserResultSummary(BaseModel):
|
||||||
|
attempt_id: str
|
||||||
|
video_hash: str
|
||||||
|
course_title: str
|
||||||
|
sentence_index: int
|
||||||
|
sentence_text: str
|
||||||
|
overall_score: float = Field(ge=0, le=100)
|
||||||
|
passed: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserDubShareSummary(BaseModel):
|
||||||
|
share_id: str
|
||||||
|
video_hash: str
|
||||||
|
course_title: str
|
||||||
|
title: str
|
||||||
|
segment_count: int = Field(ge=0)
|
||||||
|
average_score: float = Field(ge=0, le=100)
|
||||||
|
created_at: datetime
|
||||||
|
|||||||
77
sentence_api/oss.py
Normal file
77
sentence_api/oss.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VolcanoOSSUploader:
|
||||||
|
"""Upload course videos to Volcano Engine Object Storage (TOS)."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.settings.volcano_oss_enabled
|
||||||
|
|
||||||
|
def object_key(self, video_hash: str, stored_filename: str) -> str:
|
||||||
|
normalized_hash = video_hash.strip().lower()
|
||||||
|
suffix = Path(stored_filename).suffix or ".mp4"
|
||||||
|
return f"{self.settings.volcano_oss_key_prefix}{normalized_hash}{suffix}"
|
||||||
|
|
||||||
|
def public_url(self, object_key: str) -> str:
|
||||||
|
return self.settings.volcano_oss_public_url(object_key)
|
||||||
|
|
||||||
|
def upload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
video_hash: str,
|
||||||
|
stored_filename: str,
|
||||||
|
local_path: Path,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Upload one local file and return its permanent public URL."""
|
||||||
|
if not self.enabled:
|
||||||
|
raise RuntimeError("Volcano OSS is not configured.")
|
||||||
|
if not local_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Cannot upload a missing video: {local_path}")
|
||||||
|
|
||||||
|
# Keep the SDK import optional for development machines that do not run
|
||||||
|
# the production service. It is declared in sentence_api/requirements.txt.
|
||||||
|
try:
|
||||||
|
import tos
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The Volcano TOS SDK is not installed. Run: pip install tos"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
object_key = self.object_key(video_hash, stored_filename)
|
||||||
|
endpoint = self.settings.volcano_oss_endpoint
|
||||||
|
client = tos.TosClientV2(
|
||||||
|
self.settings.volcano_oss_access_key,
|
||||||
|
self.settings.volcano_oss_secret_key,
|
||||||
|
endpoint,
|
||||||
|
self.settings.volcano_oss_region,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client.put_object_from_file(
|
||||||
|
self.settings.volcano_oss_bucket,
|
||||||
|
object_key,
|
||||||
|
str(local_path),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Volcano OSS upload failed: bucket=%s key=%s endpoint=%s",
|
||||||
|
self.settings.volcano_oss_bucket,
|
||||||
|
object_key,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Uploaded %s to Volcano OSS object %s", local_path.name, object_key)
|
||||||
|
return self.public_url(object_key)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user