Compare commits

..

76 Commits

Author SHA1 Message Date
d143eb7fed fix a bug 2026-10-29 13:25:39 +08:00
a87228cdc9 fixed bugs 2026-10-29 11:32:33 +08:00
031f3c6f21 later process 2026-10-29 08:48:49 +08:00
03e7987c7c fixed some bugs 2026-10-26 20:23:57 +08:00
ed8df744f9 add readming mode 2026-10-26 17:31:08 +08:00
9b7c7d726b add function to subtitle layer 2026-10-26 12:02:41 +08:00
fe21617c63 fixed a bug 2026-10-25 11:41:08 +08:00
5428c42ffd add debug info 2026-10-25 11:31:32 +08:00
ae84347912 fff 2026-10-25 10:30:22 +08:00
38894ba80c playing in background 2026-10-24 13:04:57 +08:00
532d9900ba repaire sentences 2026-10-21 19:39:04 +08:00
176d722c77 use fast souces 2026-10-08 11:40:52 +08:00
4746331515 pc translate sentences 2026-10-08 10:08:16 +08:00
b28f809c46 add a retranslate chinese function 2026-10-06 14:47:22 +08:00
4fd0fd36a4 regenerate something when the sentences changed 2026-10-03 21:12:12 +08:00
d5609c5c42 continue fixing 2026-10-03 20:52:56 +08:00
beb29429d2 optimized playing screen 2026-10-03 20:30:05 +08:00
9daea7b807 fixed a bug:translate maybe timeout 2026-10-03 19:49:21 +08:00
6bd64ee4a9 add chinese subtitles 2026-10-03 12:14:17 +08:00
138d446d73 remove lessonTitleText in training module 2026-10-03 09:46:38 +08:00
7b63c4eab6 version changed 2026-10-03 09:38:25 +08:00
1a5a756e82 relayout of train view 2026-10-02 11:02:36 +08:00
55fad2c0cd add a refresh action 2026-09-26 10:05:48 +08:00
7fae2bb8c9 title modification 2026-09-26 08:03:20 +08:00
37ee850ee1 add Markdown surpport 2026-09-24 08:42:11 +08:00
2a7f24b1bd ios full screen 2026-09-14 17:48:42 +08:00
3aaa9f4229 add save function of explanation 2026-09-14 16:27:55 +08:00
9c78bb9b04 undo the media format convertion action 2026-09-13 17:30:28 +08:00
53e947c506 try again 2026-09-13 13:12:47 +08:00
5328898971 fixed bugs 2026-09-13 12:58:45 +08:00
f810cfedd4 fixed some bugs on ios 2026-09-13 11:35:46 +08:00
1499db0ed7 fixed some bugs 2026-09-13 09:42:39 +08:00
5856832eca fixed a bug 2026-09-12 21:27:51 +08:00
55d6860cc0 delete .tmp from git 2026-09-12 20:54:02 +08:00
ce8b765103 add Moss result to db 2026-09-12 20:50:54 +08:00
96d0ff5318 fixed a bug 2026-09-12 20:27:42 +08:00
93232a64fa return android 2026-09-12 19:58:52 +08:00
e2cc793bdb fff 2026-09-12 15:55:12 +08:00
8414f36cca ios fixed 2026-09-12 14:01:20 +08:00
ab4ff6c25a webm file supported 2026-09-12 11:28:32 +08:00
67c7ad3d0f fixed a bug 2026-09-11 12:21:02 +08:00
4b71c6c615 optimized PC player 2026-09-11 12:15:51 +08:00
07261d092b added moss 2026-09-11 09:14:51 +08:00
2b663d50ad fixed again 2026-09-10 16:16:39 +08:00
65b6617a5f fixed a bug 2026-09-10 10:02:01 +08:00
c841aa091d enable change password 2026-09-10 09:47:57 +08:00
f10915e020 fix a bug 2026-09-10 09:37:01 +08:00
9980f78cfc add sentences borders enable in pc player 2026-09-10 08:58:33 +08:00
39896a5c24 fixed some bugs 2026-09-10 08:28:07 +08:00
3908947ac2 ff 2026-09-09 12:41:08 +08:00
9d956183c7 fixed a bug 2026-09-09 12:31:05 +08:00
fb64343a9f add delete function 2026-09-08 11:25:03 +08:00
d0d43794f3 fixed some bugs 2026-09-08 11:11:49 +08:00
9230db3b69 fixed some bugs 2026-09-07 11:42:53 +08:00
aca36c78c1 fixed a bug 2026-09-06 08:32:04 +08:00
0baff0b447 add a debug info 2026-09-06 07:59:00 +08:00
701148e00e add explain module 2026-09-06 07:38:56 +08:00
7d843e8ff1 fixed again 2026-09-05 13:25:12 +08:00
6d9f9a5468 fix a bug 2026-09-05 13:09:39 +08:00
f928dd5c68 fixed a bug 2026-09-04 16:35:03 +08:00
ff1341e191 add merging sentences action 2026-09-04 09:09:34 +08:00
a38679d4a0 fixed a bug 2026-09-02 19:45:21 +08:00
cb87cf8cb1 optimized structures 2026-09-02 18:54:08 +08:00
1adba4d227 fix: send auth token with sentence boundary API requests 2026-09-01 08:41:52 +08:00
09fbe8a2de fix: restore catalogVM.controller wiring lost in RootView rewrite 2026-09-01 08:22:22 +08:00
b5934f21f5 align iOS with Android: auth, SSO login, course selection, phone binding, 4 tabs 2026-08-31 20:45:04 +08:00
f9f65988b7 fix SSO JWT token max_length: 256 -> 4096 2026-08-31 17:46:48 +08:00
b335430c18 simplify SSO: read JWT from localStorage, bypass OAuth2 authorize flow entirely 2026-08-31 17:39:29 +08:00
cb5573da73 add manual continue button for SSO login flow 2026-08-31 17:20:24 +08:00
53aed9e220 fix SSO login flow: use two-phase login then authorize to bypass d1kt redirect bug 2026-08-31 17:13:14 +08:00
85a5e76d6f fix SSO redirect URI: use https backend URL instead of custom scheme 2026-08-31 17:03:44 +08:00
daa73e2b98 add d1kt SSO integration: OAuth2 login via WebView + phone binding 2026-08-31 16:50:49 +08:00
3652ebda07 add admin user management: list and delete registered users 2026-08-31 15:54:30 +08:00
4a61afd5a9 redesign course page: select course to enter training, guard tabs without course 2026-08-31 15:37:43 +08:00
e80a770847 fix COURSE tab lateinit crash: guard refreshCurrentUi by module and loadRemoteVideo 2026-08-31 15:24:19 +08:00
a652a39b6c fix lessonTitleText lateinit crash after registration success 2026-08-31 15:11:13 +08:00
110 changed files with 18675 additions and 2588 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ sentence_api/data/v/
sentence_api/data/work/
sentence_api/data/attempts/
android/sample-app/release/
.tmp/

View File

@@ -10,8 +10,17 @@ pip 安装依赖后即可直接播放视频。
- 播放 / 暂停 / 停止、上一首 / 下一首
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
- 按句跳转:勾选后 `←` / `→` 自动播放上一句/下一句(基于音频静音自动识别
句边界,无需联网,分析结果自动缓存)
- 按句跳转:先点“生成句边界”。生成时会先按 SHA-256 查询线上是否已有同一
视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能
勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256
缓存结果;生成过程有进度条。
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
- 导出字幕:句边界就绪后可点“导出字幕”,默认保存为 UTF-8(带 BOM)的 `.ass`(中英同一条字幕避免重叠),也可另存 `.srt`。
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
`~/.mediaplayer_logs/mediaplayer.log`,不包含登录令牌。
- 音量调节、静音,0.5x - 2.0x 变速播放
- 三种循环模式:关 / 单曲循环 / 列表循环
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
@@ -77,6 +86,8 @@ pip install -r requirements.txt
| `N` / `P` | 下一首 / 上一首 |
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
| `L` | 切换循环模式(关 / 单曲 / 列表) |
| `C` | 开 / 关中文字幕 |
| 双击画面 | 进入 / 退出全屏 |
| `F` | 视频全屏切换 |
| `Esc` | 退出全屏 |
| `Ctrl+O` | 打开文件(macOS 亦支持 `Command+O`) |
@@ -98,7 +109,8 @@ pip install -r requirements.txt
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
“按句跳转”的开关状态同样会自动保存。
“按句跳转”和“中文字幕”的开关状态同样会自动保存。句边界就绪后,原文会叠在
视频画面底部;打开中文字幕时会向服务器请求翻译并一并显示。
## 常见问题

View File

@@ -1,3 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
</manifest>

View File

@@ -10,7 +10,10 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.max
@@ -30,6 +33,15 @@ class OralTrainerController internal constructor(
private var lastSentenceIndex: Int? = null
private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int? = null
private var baseItemIndex = 0
private var clippedSentence: SentenceBoundary? = null
private var updatingPlaybackWindow = false
private var unclippedDurationMs = -1L
private var sentenceEndMessage: PlayerMessage? = null
private var playFromStartAfterReady = false
private var clipStartNeedsWarmup = false
private var clipStartWarmupVolume: Float? = null
private val finishClipStartWarmupRunnable = Runnable { finishClipStartWarmup() }
var config: PlayerConfig = initialConfig
private set
@@ -44,10 +56,10 @@ class OralTrainerController internal constructor(
if (released) {
return
}
enforceSentenceBoundary()
enforceStopAtSentenceEnd()
notifySentenceIfChanged()
notifySnapshot()
mainHandler.postDelayed(this, 250L)
mainHandler.postDelayed(this, IDLE_POLL_MS)
}
}
@@ -57,12 +69,45 @@ class OralTrainerController internal constructor(
)
player = ExoPlayer.Builder(appContext)
.setMediaSourceFactory(mediaSourceFactory)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
.also { exoPlayer ->
exoPlayer.repeatMode = Player.REPEAT_MODE_ALL
exoPlayer.playWhenReady = initialConfig.autoPlay
exoPlayer.setSeekParameters(SeekParameters.EXACT)
exoPlayer.setForegroundMode(true)
exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters
.buildUpon()
.setAudioOffloadPreferences(
AudioOffloadPreferences.Builder()
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED)
.build()
)
.build()
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
if (clipStartWarmupVolume != null) {
finishClipStartWarmup()
} else {
onClippedSentenceEnded()
}
}
if (playbackState == Player.STATE_READY) {
if (playFromStartAfterReady) {
playFromStartAfterReady = false
playClippedSentenceFromStart()
}
if (stopAtSentenceEnd) {
scheduleSentenceEndGuard()
}
if (unclippedDurationMs <= 0L) {
val duration = player.duration
if (duration != C.TIME_UNSET && clippedSentence == null) {
unclippedDurationMs = duration
}
}
}
notifySnapshot()
}
@@ -71,7 +116,14 @@ class OralTrainerController internal constructor(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (updatingPlaybackWindow) {
notifySnapshot()
return
}
if (clippedSentence == null) {
baseItemIndex = player.currentMediaItemIndex
lastSentenceIndex = null
}
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
notifySentenceIfChanged(force = true)
notifySnapshot()
@@ -129,9 +181,18 @@ class OralTrainerController internal constructor(
lastSentenceIndex = null
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs))
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
baseItemIndex = safeIndex
clippedSentence = null
unclippedDurationMs = -1L
val startMs = max(0L, startPositionMs)
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, startMs)
player.prepare()
player.playWhenReady = config.autoPlay
applyLoopMode()
lockSentenceAt(startMs)
syncPlaybackWindow(startMs)
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
notifySentenceIfChanged(force = true)
notifySnapshot()
@@ -155,8 +216,10 @@ class OralTrainerController internal constructor(
return true
}
mediaItems[index] = updatedItem
if (index == player.currentMediaItemIndex) {
if (index == baseItemIndex) {
listeners.forEach { it.onMediaChanged(updatedItem) }
lockSentenceAt(playbackPositionMs())
syncPlaybackWindow()
notifySentenceIfChanged(force = true)
notifySnapshot()
}
@@ -164,13 +227,21 @@ class OralTrainerController internal constructor(
}
fun play() {
syncPlaybackWindow()
if (clippedSentence != null && !player.isPlaying && shouldWarmClipStart()) {
playClippedSentenceFromStart()
return
}
player.play()
}
fun pause() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
player.pause()
applySentenceRepeatMode()
}
fun togglePlayPause() {
@@ -184,17 +255,26 @@ class OralTrainerController internal constructor(
fun stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
player.stop()
}
fun seekTo(positionMs: Long) {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
player.seekTo(max(0L, positionMs))
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
val safePositionMs = max(0L, positionMs)
lockSentenceAt(safePositionMs)
syncPlaybackWindow(safePositionMs)
seekInCurrentWindow(safePositionMs)
notifySentenceIfChanged(force = true)
notifySnapshot()
}
fun seekRelative(deltaMs: Long) {
seekTo(player.currentPosition + deltaMs)
seekTo(playbackPositionMs() + deltaMs)
}
fun rewind(stepMs: Long = config.defaultSeekStepMs) {
@@ -206,11 +286,11 @@ class OralTrainerController internal constructor(
}
fun previousItem() {
player.seekToPreviousMediaItem()
switchBaseItem(baseItemIndex - 1)
}
fun nextItem() {
player.seekToNextMediaItem()
switchBaseItem(baseItemIndex + 1)
}
fun setPlaybackSpeed(speed: Float) {
@@ -224,40 +304,71 @@ class OralTrainerController internal constructor(
fun setLoopMode(loopMode: LoopMode) {
this.loopMode = loopMode
player.repeatMode = when (loopMode) {
LoopMode.OFF -> Player.REPEAT_MODE_OFF
LoopMode.ONE -> Player.REPEAT_MODE_ONE
LoopMode.ALL -> Player.REPEAT_MODE_ALL
}
applySentenceRepeatMode()
}
fun setSentenceMode(enabled: Boolean) {
config = config.copy(sentenceMode = enabled)
syncPlaybackWindow()
}
fun setContinuousPlayback(enabled: Boolean) {
config = config.copy(continuousPlayback = enabled)
if (!enabled) {
lockSentenceAt(playbackPositionMs())
}
syncPlaybackWindow()
notifySentenceIfChanged(force = true)
}
fun playCurrentSentenceAndStop() {
val sentence = currentSentence() ?: return
player.seekTo(sentence.startMs)
playSentenceAndStop(sentence.startMs, sentence.index)
}
/** Seek to a known sentence and stop automatically when that sentence ends. */
fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) {
val item = currentTrainingItem()
val sentence = item?.sentences?.getOrNull(sentenceIndex)
cancelClipStartWarmup(restoreVolume = true)
stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentence.index
stopAtSentenceEndIndex = sentenceIndex
lastSentenceIndex = sentenceIndex
player.playWhenReady = false
if (item != null && sentence != null) {
if (!isCurrentClip(sentence)) {
installSentenceClip(item, sentence, 0L)
player.playWhenReady = false
}
playClippedSentenceFromStart()
} else {
cancelSentenceEndGuard()
player.seekTo(max(0L, startMs))
player.playWhenReady = true
player.play()
}
notifySentenceIfChanged(force = true)
notifySnapshot()
}
fun currentTrainingItem(): TrainingMediaItem? {
val index = player.currentMediaItemIndex
return mediaItems.getOrNull(index)
return mediaItems.getOrNull(baseItemIndex)
}
fun currentSentence(): SentenceBoundary? {
val item = currentTrainingItem() ?: return null
val position = player.currentPosition
return item.sentences.lastOrNull { sentence ->
position >= sentence.startMs && position < sentence.endMs
if (item.sentences.isEmpty()) {
return null
}
clippedSentence?.let { window ->
return item.sentences.getOrNull(window.index)
}
if (shouldBoundCurrentSentence()) {
lastSentenceIndex?.let { index ->
item.sentences.getOrNull(index)?.let { return it }
}
}
return sentenceAt(item, playbackPositionMs())
}
fun seekToPreviousSentence(): Boolean {
@@ -265,11 +376,16 @@ class OralTrainerController internal constructor(
if (item.sentences.isEmpty()) {
return false
}
val position = player.currentPosition
val target = item.sentences
val target = if (shouldBoundCurrentSentence()) {
val index = anchoredSentenceIndex(item) ?: 0
item.sentences.getOrNull((index - 1).coerceAtLeast(0)) ?: item.sentences.first()
} else {
val position = playbackPositionMs()
item.sentences
.asReversed()
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
?: item.sentences.first()
}
seekTo(target.startMs)
return true
}
@@ -279,10 +395,15 @@ class OralTrainerController internal constructor(
if (item.sentences.isEmpty()) {
return false
}
val position = player.currentPosition
val target = item.sentences.firstOrNull {
val target = if (shouldBoundCurrentSentence()) {
val index = anchoredSentenceIndex(item) ?: return false
item.sentences.getOrNull(index + 1) ?: return false
} else {
val position = playbackPositionMs()
item.sentences.firstOrNull {
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
} ?: return false
}
seekTo(target.startMs)
return true
}
@@ -339,12 +460,16 @@ class OralTrainerController internal constructor(
fun snapshot(): PlaybackSnapshot {
val item = currentTrainingItem()
val duration = player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L
val duration = when {
unclippedDurationMs > 0L -> unclippedDurationMs
clippedSentence != null -> clippedSentence?.endMs ?: -1L
else -> player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L
}
return PlaybackSnapshot(
mediaId = item?.id,
positionMs = max(0L, player.currentPosition),
positionMs = playbackPositionMs(),
durationMs = duration,
bufferedPositionMs = max(0L, player.bufferedPosition),
bufferedPositionMs = playbackBufferedPositionMs(),
isPlaying = player.isPlaying,
playbackState = player.playbackState.toOralTrainerState(),
playbackSpeed = player.playbackParameters.speed,
@@ -357,6 +482,8 @@ class OralTrainerController internal constructor(
return
}
released = true
cancelClipStartWarmup(restoreVolume = false)
cancelSentenceEndGuard()
mainHandler.removeCallbacks(ticker)
listeners.clear()
player.release()
@@ -380,42 +507,291 @@ class OralTrainerController internal constructor(
listeners.forEach { it.onSentenceChanged(sentence) }
}
private fun enforceSentenceBoundary() {
if (!config.sentenceMode || !player.isPlaying) {
private fun syncPlaybackWindow(positionMs: Long = playbackPositionMs()) {
if (released || updatingPlaybackWindow) {
return
}
val item = currentTrainingItem() ?: return
if (item.sentences.isEmpty()) {
val item = currentTrainingItem()
val target = sentenceForWindow(item)
if (target == null || item == null) {
restoreUnclipped(positionMs)
return
}
val position = player.currentPosition
if (stopAtSentenceEnd) {
val index = stopAtSentenceEndIndex ?: return
val sentence = item.sentences.getOrNull(index) ?: return
if (position >= sentence.endMs) {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
player.pause()
player.seekTo(sentence.startMs)
}
val current = clippedSentence
if (current != null &&
current.index == target.index &&
current.startMs == target.startMs &&
current.endMs == target.endMs
) {
applySentenceRepeatMode()
return
}
if (config.continuousPlayback) {
return
val relativeMs = (positionMs - target.startMs).coerceAtLeast(0L)
installSentenceClip(item, target, relativeMs)
}
private fun sentenceForWindow(item: TrainingMediaItem?): SentenceBoundary? {
if (item == null || !config.sentenceMode || item.sentences.isEmpty()) {
return null
}
return when {
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null)
config.continuousPlayback -> null
else -> {
val index = lastSentenceIndex ?: sentenceAt(item, playbackPositionMs())?.index
item.sentences.getOrNull(index ?: return null)
}
val anchorIndex = lastSentenceIndex ?: return
val anchor = item.sentences.getOrNull(anchorIndex) ?: return
if (position >= anchor.endMs) {
player.seekTo(anchor.startMs)
}
}
private fun TrainingMediaItem.toMedia3Item(): MediaItem {
private fun installSentenceClip(
item: TrainingMediaItem,
sentence: SentenceBoundary,
relativePositionMs: Long,
) {
if (released) {
return
}
rememberUnclippedDuration()
cancelSentenceEndGuard()
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
val startPositionMs = relativePositionMs.coerceIn(0L, lengthMs)
val playWhenReady = player.playWhenReady
val warmFromStart = startPositionMs <= CLIP_START_WARMUP_POSITION_MS
clipStartNeedsWarmup = warmFromStart
updatingPlaybackWindow = true
clippedSentence = sentence
lastSentenceIndex = sentence.index
player.setMediaItem(item.toMedia3Item(sentence.startMs, sentence.endMs), startPositionMs)
player.prepare()
player.playWhenReady = false
applySentenceRepeatMode()
updatingPlaybackWindow = false
if (playWhenReady) {
if (warmFromStart) {
playClippedSentenceFromStart()
} else {
player.playWhenReady = true
player.play()
}
}
}
private fun restoreUnclipped(positionMs: Long) {
if (released) {
return
}
if (clippedSentence == null && player.mediaItemCount == mediaItems.size) {
applyLoopMode()
return
}
val playWhenReady = player.playWhenReady
updatingPlaybackWindow = true
clippedSentence = null
clipStartNeedsWarmup = false
player.setMediaItems(
mediaItems.map { it.toMedia3Item() },
baseItemIndex.coerceIn(0, mediaItems.lastIndex),
max(0L, positionMs),
)
player.prepare()
player.playWhenReady = playWhenReady
applyLoopMode()
updatingPlaybackWindow = false
}
private fun seekInCurrentWindow(positionMs: Long) {
val sentence = clippedSentence
if (sentence == null) {
player.seekTo(max(0L, positionMs))
return
}
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
player.seekTo((positionMs - sentence.startMs).coerceIn(0L, lengthMs))
}
private fun onClippedSentenceEnded() {
val sentence = clippedSentence ?: return
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) {
handleStopAtSentenceEnd(sentence)
return
}
clipStartNeedsWarmup = false
player.seekTo(0L)
if (player.playbackState == Player.STATE_ENDED) {
player.prepare()
}
if (!config.continuousPlayback) {
player.play()
}
lastSentenceIndex = sentence.index
notifySentenceIfChanged(force = true)
notifySnapshot()
}
private fun enforceStopAtSentenceEnd() {
if (!stopAtSentenceEnd) {
return
}
val index = stopAtSentenceEndIndex ?: return
val sentence = currentTrainingItem()?.sentences?.getOrNull(index) ?: return
if (playbackPositionMs() >= sentence.endMs) {
handleStopAtSentenceEnd(sentence)
}
}
private fun handleStopAtSentenceEnd(sentence: SentenceBoundary) {
if (!stopAtSentenceEnd || stopAtSentenceEndIndex != sentence.index) {
return
}
cancelSentenceEndGuard()
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
player.pause()
if (clippedSentence?.index == sentence.index) {
player.seekTo(0L)
if (player.playbackState == Player.STATE_ENDED) {
player.prepare()
}
} else {
seekInCurrentWindow(sentence.startMs)
}
applySentenceRepeatMode()
lastSentenceIndex = sentence.index
notifySentenceIfChanged(force = true)
notifySnapshot()
}
private fun scheduleSentenceEndGuard() {
val index = stopAtSentenceEndIndex ?: return
val sentence = currentTrainingItem()?.sentences?.getOrNull(index) ?: return
cancelSentenceEndGuard()
val endInWindowMs = if (clippedSentence?.index == sentence.index) {
(sentence.endMs - sentence.startMs - 1L).coerceAtLeast(0L)
} else {
sentence.endMs
}
sentenceEndMessage = player.createMessage { _, _ ->
if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) {
handleStopAtSentenceEnd(sentence)
}
}.setLooper(mainHandler.looper)
.setPosition(endInWindowMs)
.setDeleteAfterDelivery(true)
.send()
}
private fun cancelSentenceEndGuard() {
sentenceEndMessage?.cancel()
sentenceEndMessage = null
}
private fun switchBaseItem(targetIndex: Int) {
if (targetIndex !in mediaItems.indices) {
return
}
baseItemIndex = targetIndex
lastSentenceIndex = null
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
clippedSentence = null
unclippedDurationMs = -1L
val item = mediaItems[targetIndex]
val firstSentence = item.sentences.firstOrNull()
if (shouldBoundCurrentSentence() && firstSentence != null) {
lastSentenceIndex = firstSentence.index
installSentenceClip(item, firstSentence, 0L)
} else {
player.setMediaItem(item.toMedia3Item(), 0L)
player.prepare()
applyLoopMode()
}
listeners.forEach { it.onMediaChanged(item) }
notifySentenceIfChanged(force = true)
notifySnapshot()
}
private fun playbackPositionMs(): Long {
val sentence = clippedSentence ?: return max(0L, player.currentPosition)
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
return sentence.startMs + player.currentPosition.coerceIn(0L, lengthMs)
}
private fun playbackBufferedPositionMs(): Long {
val sentence = clippedSentence ?: return max(0L, player.bufferedPosition)
val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L)
return sentence.startMs + player.bufferedPosition.coerceIn(0L, lengthMs)
}
private fun shouldBoundCurrentSentence(): Boolean {
return config.sentenceMode && (!config.continuousPlayback || stopAtSentenceEnd)
}
private fun lockSentenceAt(positionMs: Long) {
if (!shouldBoundCurrentSentence()) {
return
}
val item = currentTrainingItem() ?: return
lastSentenceIndex = sentenceAt(item, positionMs)?.index
}
private fun anchoredSentenceIndex(item: TrainingMediaItem): Int? {
return lastSentenceIndex?.takeIf { it in item.sentences.indices }
?: clippedSentence?.index
?: sentenceAt(item, playbackPositionMs())?.index
}
private fun sentenceAt(item: TrainingMediaItem, positionMs: Long): SentenceBoundary? {
return item.sentences.lastOrNull { positionMs >= it.startMs && positionMs < it.endMs }
?: item.sentences.lastOrNull { it.startMs <= positionMs }
}
private fun rememberUnclippedDuration() {
if (unclippedDurationMs > 0L || clippedSentence != null) {
return
}
val duration = player.duration
if (duration != C.TIME_UNSET) {
unclippedDurationMs = duration
}
}
private fun applyLoopMode() {
player.repeatMode = when (loopMode) {
LoopMode.OFF -> Player.REPEAT_MODE_OFF
LoopMode.ONE -> Player.REPEAT_MODE_ONE
LoopMode.ALL -> Player.REPEAT_MODE_ALL
}
}
private fun applySentenceRepeatMode() {
if (clippedSentence == null) {
applyLoopMode()
return
}
player.repeatMode = Player.REPEAT_MODE_OFF
}
private fun TrainingMediaItem.toMedia3Item(
clippingStartPositionMs: Long = 0L,
clippingEndPositionMs: Long? = null,
): MediaItem {
val builder = MediaItem.Builder()
.setMediaId(id)
.setUri(uri)
mimeType?.let(builder::setMimeType)
customCacheKey?.let(builder::setCustomCacheKey)
if (clippingEndPositionMs != null) {
builder.setClippingConfiguration(
MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(max(0L, clippingStartPositionMs))
.setEndPositionMs(max(0L, clippingEndPositionMs))
.setStartsAtKeyFrame(false)
.build()
)
}
subtitleUri?.let { uri ->
builder.setSubtitleConfigurations(
listOf(
@@ -444,8 +820,93 @@ class OralTrainerController internal constructor(
return CancellableAssessment {}
}
private fun isCurrentClip(sentence: SentenceBoundary): Boolean {
val current = clippedSentence ?: return false
return current.index == sentence.index &&
current.startMs == sentence.startMs &&
current.endMs == sentence.endMs
}
private fun shouldWarmClipStart(): Boolean {
if (clippedSentence == null) {
return false
}
return clipStartNeedsWarmup || player.currentPosition <= CLIP_START_WARMUP_POSITION_MS
}
private fun playClippedSentenceFromStart() {
if (released) {
return
}
if (player.playbackState == Player.STATE_IDLE ||
player.playbackState == Player.STATE_ENDED ||
player.playbackState == Player.STATE_BUFFERING
) {
playFromStartAfterReady = true
player.playWhenReady = false
if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) {
player.prepare()
}
return
}
startClipStartWarmup()
}
private fun startClipStartWarmup() {
if (released) {
return
}
mainHandler.removeCallbacks(finishClipStartWarmupRunnable)
if (clipStartWarmupVolume == null) {
clipStartWarmupVolume = player.volume
}
player.volume = 0f
player.seekTo(0L)
if (player.playbackState == Player.STATE_ENDED) {
playFromStartAfterReady = true
player.prepare()
return
}
player.playWhenReady = true
player.play()
mainHandler.postDelayed(finishClipStartWarmupRunnable, CLIP_START_WARMUP_MS)
}
private fun finishClipStartWarmup() {
if (released) {
return
}
val restoreVolume = clipStartWarmupVolume ?: return
clipStartWarmupVolume = null
clipStartNeedsWarmup = false
mainHandler.removeCallbacks(finishClipStartWarmupRunnable)
player.seekTo(0L)
if (player.playbackState == Player.STATE_ENDED) {
player.prepare()
}
player.volume = restoreVolume
player.playWhenReady = true
player.play()
if (stopAtSentenceEnd) {
scheduleSentenceEndGuard()
}
}
private fun cancelClipStartWarmup(restoreVolume: Boolean) {
playFromStartAfterReady = false
mainHandler.removeCallbacks(finishClipStartWarmupRunnable)
val volume = clipStartWarmupVolume ?: return
clipStartWarmupVolume = null
if (restoreVolume) {
player.volume = volume
}
}
private companion object {
const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L
const val NEXT_SENTENCE_TOLERANCE_MS = 150L
const val IDLE_POLL_MS = 250L
const val CLIP_START_WARMUP_MS = 180L
const val CLIP_START_WARMUP_POSITION_MS = 120L
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.widget.FrameLayout
import androidx.media3.ui.PlayerView
@@ -16,6 +17,7 @@ class OralTrainerPlayerView @JvmOverloads constructor(
) : FrameLayout(context, attrs, defStyleAttr) {
val playerView: PlayerView = PlayerView(context).apply {
useController = false
setKeepContentOnPlayerReset(true)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
@@ -122,6 +124,26 @@ class OralTrainerPlayerView @JvmOverloads constructor(
verticalSwipeListener = listener
}
/** Forwards touches from an overlay (for example a subtitle layer) as player gestures. */
fun forwardGestureFrom(source: View, event: MotionEvent): Boolean {
if (controller == null || !gestureControls.enabled) {
return false
}
val sourceLocation = IntArray(2).also(source::getLocationOnScreen)
val playerLocation = IntArray(2).also(::getLocationOnScreen)
val playerEvent = MotionEvent.obtain(event).apply {
offsetLocation(
(playerLocation[0] - sourceLocation[0]).toFloat(),
(playerLocation[1] - sourceLocation[1]).toFloat(),
)
}
return try {
handleLongPressSpeed(playerEvent) || gestureDetector.onTouchEvent(playerEvent)
} finally {
playerEvent.recycle()
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
}

View File

@@ -159,6 +159,11 @@ class SentenceBoundaryApi internal constructor(
} else {
sentence.getLong("reference_speech_duration_ms")
},
zhText = if (sentence.isNull("zh_text")) {
null
} else {
sentence.getString("zh_text").takeIf { it.isNotBlank() }
},
)
)
}

View File

@@ -10,6 +10,7 @@ data class SentenceBoundary @JvmOverloads constructor(
val text: String? = null,
val language: String? = null,
val referenceSpeechDurationMs: Long? = null,
val zhText: String? = null,
) {
init {
require(index >= 0) { "Sentence index must be non-negative." }

View File

@@ -67,6 +67,21 @@ data class UserDubShare(
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,
@@ -91,7 +106,7 @@ class UserApi internal constructor(
username: String,
password: String,
nickname: String? = null,
phone: String? = null,
phone: String,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) {
requestRegisterBlocking(
@@ -109,6 +124,24 @@ class UserApi internal constructor(
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,
@@ -132,6 +165,29 @@ class UserApi internal constructor(
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()
}
@@ -178,13 +234,16 @@ class UserApi internal constructor(
username: String,
password: String,
nickname: String?,
phone: 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) }
phone?.takeIf { it.isNotBlank() }?.let { root.put("phone", it) }
root.put("phone", cleanedPhone)
val response = request(path, body = root.toString(), method = "POST")
return parseSession(JSONObject(response))
}
@@ -234,6 +293,34 @@ class UserApi internal constructor(
)
}
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,
@@ -279,4 +366,27 @@ class UserApi internal constructor(
),
)
}
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}$")

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.2"
versionName = "0.1.6"
}
compileOptions {
@@ -37,6 +37,7 @@ kotlin {
dependencies {
implementation(project(":oral-trainer-sdk"))
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.media3:media3-common:1.11.0")
implementation("androidx.media3:media3-exoplayer:1.11.0")
}

View File

@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -19,6 +22,11 @@
</intent-filter>
</activity>
<service
android:name=".TrainingPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@@ -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}$")

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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 ''; } })()"
}
}

View File

@@ -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"
}
}

View File

@@ -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("暂无句子边界", "")
}
})
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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() }
}
}
}

View File

@@ -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()
}
}

View File

@@ -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"
}
}
}

View File

@@ -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
}
}

View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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 = "(已保存本地)"
}
}

View File

@@ -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 != ".." }
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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 "刷新课程"
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
})
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
})
}
}
}

View 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>

View File

@@ -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>

View File

@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">八哥口语</string>
<string name="training_playback_channel_name">训练播放</string>
<string name="training_playback_notification_title">口语训练</string>
<string name="training_playback_notification_text">正在后台播放</string>
</resources>

View File

@@ -1,3 +1,6 @@
<resources>
<string name="app_name">MynaTalk</string>
<string name="training_playback_channel_name">Training playback</string>
<string name="training_playback_notification_title">Oral training</string>
<string name="training_playback_notification_text">Playing in the background</string>
</resources>

View File

@@ -7,6 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; };
AA00000000000000000000A1 /* UserHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A2 /* UserHistory.swift */; };
AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A4 /* TrainingHistoryStore.swift */; };
AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SentenceExplanationStore.swift */; };
AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; };
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
@@ -30,9 +35,15 @@
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; };
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A6 /* StreamingVideoCache.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = "<group>"; };
AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = "<group>"; };
AA00000000000000000000B1 /* SentenceExplanationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceExplanationStore.swift; sourceTree = "<group>"; };
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -58,6 +69,7 @@
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = "<group>"; };
AA00000000000000000000A6 /* StreamingVideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingVideoCache.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -117,10 +129,15 @@
children = (
AA0000000000000000000028 /* HTTPClient.swift */,
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
AA0000000000000000000091 /* AuthAPI.swift */,
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
AA000000000000000000002B /* ImitationAssessor.swift */,
AA00000000000000000000A4 /* TrainingHistoryStore.swift */,
AA00000000000000000000B1 /* SentenceExplanationStore.swift */,
AA00000000000000000000A2 /* UserHistory.swift */,
AA000000000000000000007E /* DubbingCompositor.swift */,
AA0000000000000000000081 /* AudioRecorderSession.swift */,
AA00000000000000000000A6 /* StreamingVideoCache.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -139,6 +156,7 @@
children = (
AA000000000000000000002E /* Theme.swift */,
AA000000000000000000002F /* RootView.swift */,
AA0000000000000000000093 /* AuthView.swift */,
AA0000000000000000000030 /* TrainingView.swift */,
AA0000000000000000000031 /* PlayerSectionView.swift */,
AA0000000000000000000032 /* CatalogView.swift */,
@@ -241,6 +259,7 @@
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */,
AA000000000000000000004C /* PlayerController.swift in Sources */,
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
AA000000000000000000004E /* Theme.swift in Sources */,
@@ -248,6 +267,11 @@
AA0000000000000000000050 /* TrainingView.swift in Sources */,
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
AA0000000000000000000052 /* CatalogView.swift in Sources */,
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
AA00000000000000000000A1 /* UserHistory.swift in Sources */,
AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */,
AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */,
AA0000000000000000000092 /* AuthView.swift in Sources */,
AA0000000000000000000053 /* TestView.swift in Sources */,
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
);

View File

@@ -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>

View File

@@ -9,4 +9,38 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
) -> UIInterfaceOrientationMask {
AppDelegate.orientationLock
}
static func lockOrientation(_ mask: UIInterfaceOrientationMask) {
orientationLock = mask
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
else {
return
}
func update(_ controller: UIViewController?) {
guard let controller else { return }
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
update(controller.presentedViewController)
controller.children.forEach { update($0) }
}
let requested: UIInterfaceOrientationMask
if mask.contains(.landscapeRight) {
requested = .landscapeRight
} else if mask.contains(.landscapeLeft) {
requested = .landscapeLeft
} else {
requested = mask
}
scene.windows.forEach { update($0.rootViewController) }
scene.requestGeometryUpdate(.iOS(interfaceOrientations: requested)) { _ in }
DispatchQueue.main.async {
scene.windows.forEach { update($0.rootViewController) }
scene.requestGeometryUpdate(.iOS(interfaceOrientations: requested)) { _ in }
}
}
}

View File

@@ -15,6 +15,15 @@ enum AppConfig {
static let connectTimeout: TimeInterval = 15
static let readTimeout: TimeInterval = 30
static let assessmentTimeout: TimeInterval = 180
static let translationTimeout: TimeInterval = 30
static let translationDeadline: TimeInterval = 900
static let translationPollIntervalNs: UInt64 = 1_500_000_000
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
static var authToken: String = ""
/// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。
static var currentUserId: String = ""
}

View File

@@ -50,9 +50,35 @@ enum GestureKind {
case longPressSpeed
}
enum SwipeBackResult {
case restartedCurrent
case movedPrevious
case stayedAtFirst
case rewound
}
enum BoundaryState: Equatable {
case idle
case loading(isLocal: Bool)
case loaded(count: Int)
case failed(message: String)
}
/// 云端视频按需流式播放;若本地已有完整缓存文件则优先使用。
enum VideoCacheState: Equatable {
case notRequired
case downloading
case ready
case failed(message: String)
var statusMessage: String? {
switch self {
case .notRequired, .ready:
return nil
case .downloading:
return "正在缓冲视频…"
case .failed(let message):
return "视频加载失败:\(message)"
}
}
}

View File

@@ -7,6 +7,7 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
let text: String?
let language: String?
let referenceSpeechDurationMs: Int64?
let zhText: String?
var id: Int { index }
@@ -15,6 +16,48 @@ struct SentenceBoundary: Identifiable, Equatable, Decodable {
case startMs = "start_ms"
case endMs = "end_ms"
case referenceSpeechDurationMs = "reference_speech_duration_ms"
case zhText = "zh_text"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
index = try container.decode(Int.self, forKey: .index)
startMs = try container.decode(Int64.self, forKey: .startMs)
endMs = try container.decode(Int64.self, forKey: .endMs)
text = try container.decodeIfPresent(String.self, forKey: .text)
language = try container.decodeIfPresent(String.self, forKey: .language)
referenceSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceSpeechDurationMs)
zhText = try container.decodeIfPresent(String.self, forKey: .zhText)
}
init(
index: Int,
startMs: Int64,
endMs: Int64,
text: String?,
language: String?,
referenceSpeechDurationMs: Int64?,
zhText: String? = nil
) {
self.index = index
self.startMs = startMs
self.endMs = endMs
self.text = text
self.language = language
self.referenceSpeechDurationMs = referenceSpeechDurationMs
self.zhText = zhText
}
func withZhText(_ zhText: String?) -> SentenceBoundary {
SentenceBoundary(
index: index,
startMs: startMs,
endMs: endMs,
text: text,
language: language,
referenceSpeechDurationMs: referenceSpeechDurationMs,
zhText: zhText
)
}
}
@@ -66,6 +109,19 @@ struct TrainingVideoSummary: Identifiable, Decodable {
}
return URL(string: absolute)
}
func copy(durationMs: Int64? = self.durationMs, sentenceCount: Int = self.sentenceCount) -> Self {
TrainingVideoSummary(
videoHash: videoHash,
title: title,
streamUrl: streamUrl,
durationMs: durationMs,
sizeBytes: sizeBytes,
language: language,
sentenceCount: sentenceCount,
status: status
)
}
}
struct TrainingMediaItem: Identifiable, Equatable {

View File

@@ -10,6 +10,7 @@ final class PlayerController: ObservableObject {
@Published private(set) var currentSentence: SentenceBoundary?
@Published var boundaryState: BoundaryState = .idle
@Published var transientMessage: String?
@Published private(set) var cacheState: VideoCacheState = .notRequired
let player = AVPlayer()
@@ -20,10 +21,28 @@ final class PlayerController: ObservableObject {
private var lastSentenceIndex: Int?
private var pendingSeekPositionMs: Int64?
private var seekGeneration = 0
/// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在
/// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断
/// 用户是否希望继续播放。
private var playbackIntent = false
private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int?
private var swipeBackAnchorIndex: Int?
private var swipeBackAnchorTime: TimeInterval = 0
private var speedBeforeLongPress: Float?
private var transientTask: Task<Void, Never>?
private var cacheFallbackTask: Task<Void, Never>?
private var activePlaybackURL: URL?
private var didAttemptFullFileFallback = false
private var sentenceEndTimeObserver: Any?
private var sentenceEndGuardToken = 0
private static let swipeBackPhaseTimeout: TimeInterval = 1.2
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
/// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。
private static let sentenceSeekInsetMs: Int64 = 20
private static let previousSentenceToleranceMs: Int64 = 250
private static let nextSentenceToleranceMs: Int64 = 150
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
@@ -33,6 +52,7 @@ final class PlayerController: ObservableObject {
init() {
configureAudioSession()
player.automaticallyWaitsToMinimizeStalling = true
player.actionAtItemEnd = .pause
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
@@ -48,6 +68,7 @@ final class PlayerController: ObservableObject {
}
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async {
self?.refreshSentenceBoundaryGuard()
self?.tick()
}
}
@@ -61,38 +82,76 @@ final class PlayerController: ObservableObject {
if let endObserver = endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
cancelSentenceBoundaryGuard()
itemStatusObserver?.invalidate()
timeControlObserver?.invalidate()
}
// MARK: - 装载
@MainActor
func load(_ item: TrainingMediaItem) {
cancelSentenceBoundaryGuard()
cacheFallbackTask?.cancel()
cacheFallbackTask = nil
didAttemptFullFileFallback = false
lastSentenceIndex = nil
swipeBackAnchorIndex = nil
pendingSeekPositionMs = nil
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
mediaItem = item
currentItem = item
boundaryState = .idle
let playerItem = AVPlayerItem(url: item.url)
attachItemObservers(to: playerItem)
player.replaceCurrentItem(with: playerItem)
if config.autoPlay {
player.play()
if item.isLocalFile {
cacheState = .notRequired
preparePlaybackItem(with: item.url)
} else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) {
cacheState = .ready
preparePlaybackItem(with: cachedURL)
} else {
cacheState = .notRequired
preparePlaybackItem(with: item.url)
}
}
/// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。
/// 否则会把 AVFoundation 已经缓存的媒体数据丢掉,回跳上一句时可能重新联网。
@MainActor
func updateBoundaries(_ sentences: [SentenceBoundary]) {
guard let oldItem = mediaItem else {
return
}
let newItem = TrainingMediaItem(
id: oldItem.id,
title: oldItem.title,
url: oldItem.url,
sentences: sentences,
isLocalFile: oldItem.isLocalFile
)
mediaItem = newItem
currentItem = newItem
lastSentenceIndex = nil
tick()
}
// MARK: - 播放控制
func play() {
playbackIntent = true
player.play()
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
refreshSentenceBoundaryGuard(force: true)
}
func pause() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
cancelSentenceBoundaryGuard()
player.pause()
}
@@ -111,6 +170,8 @@ final class PlayerController: ObservableObject {
func stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
cancelSentenceBoundaryGuard()
player.pause()
seek(to: .zero)
}
@@ -140,12 +201,14 @@ final class PlayerController: ObservableObject {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
// 播放点落在两句之间的空隙时也能正确定位前一句。
let position = referencePositionMs()
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
?? item.sentences.first else {
return false
}
seek(to: target.startMs)
let target = item.sentences
.reversed()
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
?? item.sentences[0]
seekToSentenceBeginning(target)
return true
}
@@ -154,14 +217,49 @@ final class PlayerController: ObservableObject {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
// Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间
// 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。
let position = referencePositionMs()
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
guard let target = item.sentences.first(where: {
$0.startMs > position + Self.nextSentenceToleranceMs
}) else {
return false
}
seek(to: target.startMs)
seekToSentenceBeginning(target)
return true
}
/// 按索引跳转,而不是每次用异步的 AVPlayer 时间重新搜索。
/// 这样一次手势只前进/后退一句,也不会因 seek 后时间上报偏差导致重复或无效。
@discardableResult
func seekToSentence(at index: Int) -> Bool {
guard let item = mediaItem, item.sentences.indices.contains(index) else {
return false
}
seekToSentenceBeginning(item.sentences[index])
return true
}
private func sentenceAnchorIndex() -> Int? {
guard let item = mediaItem, !item.sentences.isEmpty else { return nil }
if let index = currentSentence?.index, item.sentences.indices.contains(index) {
return index
}
if let index = lastSentenceIndex, item.sentences.indices.contains(index) {
return index
}
let position = referencePositionMs()
if let sentence = sentence(at: position) {
return sentence.index
}
// 句间隙中时,取前一句作为锚点;在第一句之前时取下一句。
if let previous = item.sentences.last(where: { $0.startMs <= position }) {
return previous.index
}
return item.sentences.first?.index
}
func previousSentenceOrRewind() {
if !config.sentenceMode || !seekToPreviousSentence() {
rewind()
@@ -174,25 +272,76 @@ final class PlayerController: ObservableObject {
}
}
/// 播放中向左划:第一次回到当前句开头重播;短时间内再划一次才到上一句。
/// 暂停时保持原来的“上一句”语义,测试页“上一句”按钮也不受影响。
@discardableResult
func replayOrPreviousSentence() -> SwipeBackResult {
guard config.sentenceMode,
let item = mediaItem,
!item.sentences.isEmpty,
let anchorIndex = sentenceAnchorIndex() else {
rewind()
return .rewound
}
let isActivelyPlaying = player.timeControlStatus == .playing
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
guard isActivelyPlaying else {
swipeBackAnchorIndex = nil
return seekToPreviousSentence() ? .movedPrevious : .stayedAtFirst
}
let now = Date().timeIntervalSinceReferenceDate
if swipeBackAnchorIndex == anchorIndex,
now - swipeBackAnchorTime <= Self.swipeBackPhaseTimeout {
swipeBackAnchorIndex = nil
guard anchorIndex > 0 else {
seekToSentenceBeginning(item.sentences[anchorIndex])
return .stayedAtFirst
}
seekToSentenceBeginning(item.sentences[anchorIndex - 1])
return .movedPrevious
}
swipeBackAnchorIndex = anchorIndex
swipeBackAnchorTime = now
let current = item.sentences[anchorIndex]
if stopAtSentenceEnd {
playCurrentSentenceAndStop()
} else {
seekToSentenceBeginning(current)
play()
}
return .restartedCurrent
}
/// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。
/// 必须先打上句尾标记再 seek,这样 completion 里的 forwardPlaybackEndTime
/// 会卡在当前句 endMs,而不是等 250ms 观察器才停。
func playCurrentSentenceAndStop() {
guard let sentence = currentSentence else {
return
}
performSeek(to: sentence.startMs)
stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentence.index
play()
lastSentenceIndex = sentence.index
playbackIntent = true
performSeek(to: sentenceSeekPosition(sentence), resumePlayback: true)
}
// MARK: - 设置
func setContinuousPlayback(_ enabled: Bool) {
config.continuousPlayback = enabled
if !enabled {
lastSentenceIndex = currentSentence?.index ?? lastSentenceIndex
}
refreshSentenceBoundaryGuard(force: true)
}
func setSentenceMode(_ enabled: Bool) {
config.sentenceMode = enabled
refreshSentenceBoundaryGuard()
}
func setLoopMode(_ mode: LoopMode) {
@@ -249,8 +398,16 @@ final class PlayerController: ObservableObject {
togglePlayPause()
showTransientMessage("播放状态已切换")
case .swipeLeft:
previousSentenceOrRewind()
switch replayOrPreviousSentence() {
case .restartedCurrent:
showTransientMessage("已回到本句开头,再左划返回上一句")
case .movedPrevious:
showTransientMessage("已跳到上一句")
case .stayedAtFirst:
showTransientMessage("已是第一句")
case .rewound:
showTransientMessage("已快退")
}
case .swipeRight:
nextSentenceOrForward()
showTransientMessage("已跳到下一句")
@@ -290,6 +447,9 @@ final class PlayerController: ObservableObject {
if sentence?.index != lastSentenceIndex {
lastSentenceIndex = sentence?.index
}
if sentence?.index != swipeBackAnchorIndex {
swipeBackAnchorIndex = nil
}
let duration = durationMs(of: item)
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
snapshot = PlaybackSnapshot(
@@ -318,10 +478,46 @@ final class PlayerController: ObservableObject {
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) {
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
@@ -336,6 +532,13 @@ final class PlayerController: ObservableObject {
}
self.pendingSeekPositionMs = nil
self.tick()
if shouldResumePlayback {
self.player.play()
}
completion?()
// seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。
self.refreshSentenceBoundaryGuard(force: shouldResumePlayback)
self.tick()
}
}
tick()
@@ -422,12 +625,137 @@ final class PlayerController: ObservableObject {
return
}
if positionMs >= anchor.endMs {
seekRaw(anchor.startMs)
seekRaw(sentenceSeekPosition(anchor))
}
}
private func seekRaw(_ positionMs: Int64) {
performSeek(to: positionMs)
private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) {
performSeek(to: positionMs, completion: completion)
}
/// AVPlayer 的 periodic observer 间隔是 250ms。仅靠它回收句尾时,
/// 最坏情况已经播进下一句约 250ms。这里参照安卓端 PlayerMessage 的做法,
/// 在当前句 endMs 精确注册 boundary observer,到点立即执行句尾动作。
private func refreshSentenceBoundaryGuard(force: Bool = false) {
cancelSentenceBoundaryGuard()
guard config.sentenceMode,
let item = mediaItem,
!item.sentences.isEmpty else {
clearForwardPlaybackEnd()
return
}
guard force || isActivelyPlaying() else {
return
}
guard let sentence = sentenceBoundaryTarget(in: item) else {
clearForwardPlaybackEnd()
return
}
let currentPosition = referencePositionMs()
if currentPosition >= sentence.endMs {
applySentenceEndAction(sentence)
return
}
let token = sentenceEndGuardToken
let endTime = CMTime(value: CMTimeValue(sentence.endMs), timescale: 1000)
// boundary observer 负责回到句首;forwardPlaybackEndTime 是解码层硬边界,
// 即使主线程回调稍晚,AVPlayer 也不会继续渲染 endMs 之后的内容。
player.currentItem?.forwardPlaybackEndTime = endTime
sentenceEndTimeObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: endTime)],
queue: .main
) { [weak self] in
guard let self, token == self.sentenceEndGuardToken else {
return
}
self.applySentenceEndAction(sentence)
}
}
private func cancelSentenceBoundaryGuard() {
sentenceEndGuardToken += 1
if let observer = sentenceEndTimeObserver {
player.removeTimeObserver(observer)
}
sentenceEndTimeObserver = nil
}
private func clearForwardPlaybackEnd() {
player.currentItem?.forwardPlaybackEndTime = .invalid
}
private func isActivelyPlaying() -> Bool {
player.timeControlStatus == .playing
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
}
private func sentenceBoundaryTarget(in item: TrainingMediaItem) -> SentenceBoundary? {
if stopAtSentenceEnd {
guard let index = stopAtSentenceEndIndex else {
return nil
}
return item.sentences.first(where: { $0.index == index })
}
if config.continuousPlayback {
return nil
}
if let index = lastSentenceIndex ?? currentSentence?.index,
let sentence = item.sentences.first(where: { $0.index == index }) {
return sentence
}
let position = referencePositionMs()
if let sentence = sentence(at: position) {
return sentence
}
return item.sentences.last(where: { $0.startMs <= position })
}
private func applySentenceEndAction(_ target: SentenceBoundary) {
cancelSentenceBoundaryGuard()
guard let item = mediaItem,
let sentence = item.sentences.first(where: { $0.index == target.index }) else {
return
}
lastSentenceIndex = sentence.index
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
player.pause()
seekRaw(sentenceSeekPosition(sentence))
tick()
return
}
if config.continuousPlayback {
clearForwardPlaybackEnd()
return
}
// forwardPlaybackEndTime 到点后 AVPlayer 会自动暂停。这里先回到句首,
// 等 seek 完成后再恢复播放,实现同一句循环且不混入下一句内容。
player.pause()
seekRaw(sentenceSeekPosition(sentence)) { [weak self] in
guard let self else {
return
}
self.player.play()
self.refreshSentenceBoundaryGuard(force: true)
self.tick()
}
}
private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 {
let available = max(0, sentence.endMs - sentence.startMs - 1)
let inset = min(Self.sentenceSeekInsetMs, available)
return sentence.startMs + inset
}
private func seekToSentenceBeginning(_ sentence: SentenceBoundary) {
seek(to: sentenceSeekPosition(sentence))
}
private func handleEnded() {
@@ -435,9 +763,53 @@ final class PlayerController: ObservableObject {
tick()
return
}
performSeek(to: .zero)
player.play()
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
// 不能再被识别成整条视频结束。
guard pendingSeekPositionMs == nil else {
tick()
return
}
// 评测“播放原句”即使处于连续播放,也要用当前句硬边界,不能滑进下一句。
if stopAtSentenceEnd,
let index = stopAtSentenceEndIndex,
let sentence = mediaItem?.sentences.first(where: { $0.index == index }) {
applySentenceEndAction(sentence)
return
}
// 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束
// 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。
if config.sentenceMode,
!config.continuousPlayback,
let sentence = currentSentence,
currentPositionMs() + 30 >= sentence.endMs {
applySentenceEndAction(sentence)
return
}
// 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。
// 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。
guard isNaturalMediaEnd() else {
tick()
return
}
// 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停,
// 则不应因为到达 media end 而突然自动循环。
performSeek(to: .zero)
tick()
}
private func isNaturalMediaEnd() -> Bool {
guard let item = player.currentItem else {
return true
}
let durationMs = durationMs(of: item)
let positionMs = currentPositionMs()
if durationMs > 0 {
return positionMs + 30 >= durationMs
}
guard let sentence = currentSentence else {
return true
}
return positionMs + 30 >= sentence.endMs
}
// MARK: - 观察器
@@ -452,12 +824,57 @@ final class PlayerController: ObservableObject {
if item.status == .failed {
let message = item.error?.localizedDescription ?? "未知错误"
self.showTransientMessage("播放失败:\(message)")
} else if item.status == .readyToPlay {
self.considerFullFileFallback(for: item)
}
self.tick()
}
}
}
private func considerFullFileFallback(for item: AVPlayerItem) {
guard !didAttemptFullFileFallback,
let media = mediaItem,
!media.isLocalFile,
!(activePlaybackURL?.isFileURL ?? false) else {
return
}
let hasVisibleVideo = item.presentationSize.height >= 2 && item.presentationSize.width >= 2
if hasVisibleVideo {
return
}
didAttemptFullFileFallback = true
cacheFallbackTask?.cancel()
cacheFallbackTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 800_000_000)
guard let self, !Task.isCancelled else {
return
}
guard self.mediaItem?.id == media.id,
self.player.currentItem === item,
item.presentationSize.height < 2 else {
return
}
do {
let cachedURL = try await StreamingVideoCache.shared.prefetch(media.url)
guard self.mediaItem?.id == media.id else {
return
}
let shouldPlay = self.playbackIntent
let positionMs = self.pendingSeekPositionMs ?? self.currentPositionMs()
self.preparePlaybackItem(with: cachedURL)
self.cacheState = .ready
if positionMs > 0 {
self.performSeek(to: positionMs, resumePlayback: shouldPlay)
}
} catch is CancellationError {
return
} catch {
self.showTransientMessage("视频画面加载失败:\(error.localizedDescription)")
}
}
}
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .moviePlayback)

View File

@@ -13,15 +13,20 @@ struct PlayerSurfaceView: UIViewRepresentable {
func makeUIView(context: Context) -> VideoSurfaceView {
let view = VideoSurfaceView()
view.playerLayer.player = controller.player
view.controller = controller
view.onVerticalSwipe = onVerticalSwipe
view.attach(player: controller.player)
view.setContentHuggingPriority(.defaultLow, for: .vertical)
view.setContentHuggingPriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
uiView.playerLayer.player = controller.player
uiView.controller = controller
uiView.onVerticalSwipe = onVerticalSwipe
uiView.attach(player: controller.player)
}
}
@@ -29,13 +34,9 @@ final class VideoSurfaceView: UIView {
weak var controller: PlayerController?
var onVerticalSwipe: ((Bool) -> Void)?
override class var layerClass: AnyClass {
AVPlayerLayer.self
}
private let videoLayer = AVPlayerLayer()
var playerLayer: AVPlayerLayer {
layer as! AVPlayerLayer
}
var playerLayer: AVPlayerLayer { videoLayer }
private let minSwipeDistance: CGFloat = 48
private let minSwipeVelocity: CGFloat = 160
@@ -44,11 +45,22 @@ final class VideoSurfaceView: UIView {
private var tapRecognizer: UITapGestureRecognizer!
private var panRecognizer: UIPanGestureRecognizer!
private var longPressRecognizer: UILongPressGestureRecognizer!
private var didDispatchSwipe = false
private var swipeAxis: SwipeAxis?
private enum SwipeAxis {
case horizontal
case vertical
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
playerLayer.videoGravity = .resizeAspect
isOpaque = true
clipsToBounds = true
videoLayer.videoGravity = .resizeAspect
videoLayer.backgroundColor = UIColor.black.cgColor
layer.addSublayer(videoLayer)
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@@ -75,30 +87,87 @@ final class VideoSurfaceView: UIView {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer.frame = bounds
CATransaction.commit()
}
func attach(player: AVPlayer) {
if videoLayer.player !== player {
videoLayer.player = player
}
if videoLayer.superlayer !== layer {
layer.addSublayer(videoLayer)
}
videoLayer.frame = bounds
videoLayer.isHidden = false
}
@objc private func handleTap() {
controller?.handleGesture(.singleTap)
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
guard recognizer.state == .ended || recognizer.state == .cancelled else {
return
}
let translation = recognizer.translation(in: self)
let velocity = recognizer.velocity(in: self)
let dx = translation.x
let dy = translation.y
if abs(dy) > abs(dx),
abs(dy) >= minSwipeDistance,
abs(velocity.y) >= minSwipeVelocity {
onVerticalSwipe?(dy < 0)
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),
abs(dx) >= minSwipeDistance,
abs(velocity.x) >= minSwipeVelocity else {
return
}
didDispatchSwipe = true
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
default:
break
}
}
private func resetSwipeTracking() {
didDispatchSwipe = false
swipeAxis = nil
}
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {

View File

@@ -33,6 +33,8 @@
<string>需要麦克风权限才能进行录音评测</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View 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)
}
}

View File

@@ -25,14 +25,17 @@ enum HTTPClient {
_ type: T.Type,
baseURL: String,
path: String,
timeout: TimeInterval
timeout: TimeInterval,
additionalHeaders: [String: String] = [:]
) async throws -> (value: T, effectiveBaseURL: String) {
var headers = ["Accept": "application/json"]
headers.merge(additionalHeaders) { _, new in new }
let result = try await execute(
baseURL: baseURL,
path: path,
method: "GET",
timeout: timeout,
headers: ["Accept": "application/json"]
headers: headers
)
guard (200...299).contains(result.statusCode) else {
let body = String(data: result.body, encoding: .utf8) ?? ""

View File

@@ -88,16 +88,20 @@ enum ImitationAssessor {
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
body.append(Data("--\(boundary)--\r\n".utf8))
var headers = [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
"X-Client-Key": AppConfig.assessmentAPIKey,
]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "POST",
timeout: AppConfig.assessmentTimeout,
headers: [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
"X-Client-Key": AppConfig.assessmentAPIKey,
],
headers: headers,
body: body
)
guard (200...299).contains(result.statusCode) else {
@@ -171,15 +175,19 @@ enum ImitationAssessor {
}
body.append(Data("--\(boundary)--\r\n".utf8))
var headers = [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/dub-shares",
method: "POST",
timeout: 120,
headers: [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
],
headers: headers,
body: body
)
guard (200...299).contains(result.statusCode) else {

View File

@@ -20,11 +20,16 @@ enum SentenceBoundaryAPI {
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash
}
var headers: [String: String] = [:]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.getJSON(
SentenceBoundaryDocument.self,
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/sentence-boundaries",
timeout: AppConfig.readTimeout
timeout: AppConfig.readTimeout,
additionalHeaders: headers
)
guard result.value.videoHash.lowercased() == hash else {
throw SentenceBoundaryAPIError.hashMismatch
@@ -32,3 +37,186 @@ enum SentenceBoundaryAPI {
return result.value
}
}
struct SentenceExplanationResponse: Decodable {
let explanation: String
private enum CodingKeys: String, CodingKey {
case explanation
}
}
struct SubtitleTranslationItem: Decodable {
let index: Int
let zhText: String
private enum CodingKeys: String, CodingKey {
case index
case zhText = "zh_text"
}
}
struct SubtitleTranslationResponse: Decodable {
let videoHash: String
let translations: [SubtitleTranslationItem]
let complete: Bool?
let status: String?
let error: String?
private enum CodingKeys: String, CodingKey {
case translations
case videoHash = "video_hash"
case complete
case status
case error
}
var mapped: [Int: String] {
var values: [Int: String] = [:]
for item in translations where !item.zhText.isEmpty {
values[item.index] = item.zhText
}
return values
}
}
extension SentenceBoundaryAPI {
static func explain(
_ sentence: SentenceBoundary,
courseTitle: String
) async throws -> String {
var body: [String: Any] = [
"sentence_text": sentence.text ?? "",
"course_title": courseTitle,
"sentence_index": sentence.index,
]
if let language = sentence.language {
body["language"] = language
}
var headers: [String: String] = ["Content-Type": "application/json"]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/sentences/explain",
method: "POST",
timeout: AppConfig.readTimeout,
headers: headers,
body: try JSONSerialization.data(withJSONObject: body)
)
guard (200...299).contains(result.statusCode) else {
let detail = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, detail)
}
return try JSONDecoder()
.decode(SentenceExplanationResponse.self, from: result.body)
.explanation
}
static func fetchStoredSubtitles(videoHash: String) async throws -> SubtitleTranslationResponse {
try await requestSubtitles(videoHash: videoHash, method: "GET", timeout: AppConfig.readTimeout)
}
static func translateSubtitles(videoHash: String) async throws -> SubtitleTranslationResponse {
try await requestSubtitles(videoHash: videoHash, method: "POST", timeout: AppConfig.translationTimeout)
}
static func loadSubtitles(
videoHash: String,
expectedIndexes: [Int],
onProgress: (([Int: String]) -> Void)? = nil
) async throws -> [Int: String] {
let expected = Set(expectedIndexes)
let deadline = Date().addingTimeInterval(AppConfig.translationDeadline)
var kickJob = true
var lastError: Error?
var lastCount = -1
var stalledRounds = 0
while Date() < deadline {
try Task.checkCancellation()
if let stored = try? await fetchStoredSubtitles(videoHash: videoHash) {
let mapped = stored.mapped
onProgress?(mapped)
if isComplete(mapped, expected: expected, payload: stored) {
return mapped
}
if mapped.count > lastCount {
lastCount = mapped.count
stalledRounds = 0
} else {
stalledRounds += 1
}
if stored.status != "running" && stalledRounds >= 2 {
kickJob = true
}
} else {
kickJob = true
}
if kickJob {
do {
let posted = try await translateSubtitles(videoHash: videoHash)
let mapped = posted.mapped
onProgress?(mapped)
if isComplete(mapped, expected: expected, payload: posted) {
return mapped
}
if posted.status == "error" {
lastError = HTTPClientError.badStatus(503, posted.error ?? "Subtitle translation failed")
}
} catch {
lastError = error
if !isTimeout(error) {
stalledRounds += 1
}
}
kickJob = false
}
try await Task.sleep(nanoseconds: AppConfig.translationPollIntervalNs)
}
throw lastError ?? HTTPClientError.badStatus(408, "Subtitle translation timed out")
}
private static func isComplete(
_ mapped: [Int: String],
expected: Set<Int>,
payload: SubtitleTranslationResponse
) -> Bool {
if !expected.isEmpty {
return expected.isSubset(of: Set(mapped.keys))
}
return payload.complete == true
}
private static func isTimeout(_ error: Error) -> Bool {
let nsError = error as NSError
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
}
private static func requestSubtitles(
videoHash: String,
method: String,
timeout: TimeInterval
) async throws -> SubtitleTranslationResponse {
let hash = videoHash.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash
}
var headers: [String: String] = ["Accept": "application/json"]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/subtitle-translation",
method: method,
timeout: timeout,
headers: headers
)
guard (200...299).contains(result.statusCode) else {
let detail = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, detail)
}
return try JSONDecoder().decode(SubtitleTranslationResponse.self, from: result.body)
}
}

View 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
}
}

View 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
}
}
}

View 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
}
}

View 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)"]
}
}

View 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)"
}
}
}

View File

@@ -10,6 +10,7 @@ final class CatalogViewModel: ObservableObject {
private var baseURL = AppConfig.serverBaseURL
private var hasLoaded = false
private var boundaryDocuments: [String: SentenceBoundaryDocument] = [:]
func loadIfNeeded() async {
guard !hasLoaded else {
@@ -31,6 +32,42 @@ final class CatalogViewModel: ObservableObject {
}
}
func preloadBoundaries(for videos: [TrainingVideoSummary]) async -> (
videos: [TrainingVideoSummary],
succeeded: Int,
failed: Int
) {
var updatedVideos = videos
var succeeded = 0
var failed = 0
for (index, video) in videos.enumerated() {
do {
let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash)
boundaryDocuments[document.videoHash] = document
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
updatedVideos[index] = video.copy(
durationMs: document.durationMs,
sentenceCount: document.sentences.count
)
if activeVideoHash == video.videoHash {
controller?.updateBoundaries(document.sentences)
controller?.boundaryState = .loaded(count: document.sentences.count)
controller?.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
}
succeeded += 1
} catch {
failed += 1
}
}
videos = updatedVideos
return (updatedVideos, succeeded, failed)
}
/// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。
func select(_ video: TrainingVideoSummary) {
guard let controller = controller else {
@@ -41,6 +78,23 @@ final class CatalogViewModel: ObservableObject {
controller.boundaryState = .failed(message: "无法解析视频流地址")
return
}
if let document = boundaryDocuments[video.videoHash] {
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
let item = TrainingMediaItem(
id: video.videoHash,
title: video.title,
url: url,
sentences: document.sentences
)
controller.load(item)
controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
return
}
let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url)
controller.load(loadingItem)
controller.boundaryState = .loading(isLocal: false)
@@ -51,13 +105,12 @@ final class CatalogViewModel: ObservableObject {
guard activeVideoHash == video.videoHash else {
return
}
let item = TrainingMediaItem(
id: video.videoHash,
title: video.title,
url: url,
boundaryDocuments[document.videoHash] = document
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
controller.load(item)
controller.updateBoundaries(document.sentences)
controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
} catch {
@@ -101,14 +154,11 @@ final class CatalogViewModel: ObservableObject {
guard activeVideoHash == hash else {
return
}
let loadedItem = TrainingMediaItem(
id: hash,
title: fileName,
url: tempURL,
sentences: document.sentences,
isLocalFile: true
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
controller.load(loadedItem)
controller.updateBoundaries(document.sentences)
controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
} catch {

View File

@@ -3,11 +3,39 @@ import SwiftUI
/// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。
struct FullscreenPlayerView: View {
@ObservedObject var controller: PlayerController
var showsLessonTitle: Bool = true
var showChineseSubtitles: Bool = false
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
let onExit: () -> Void
@State private var subtitleVisible = true
@State private var explanationSentence: SentenceBoundary?
var body: some View {
GeometryReader { proxy in
let fakeLandscape = proxy.size.height > proxy.size.width
playerChrome
.frame(
width: fakeLandscape ? proxy.size.height : proxy.size.width,
height: fakeLandscape ? proxy.size.width : proxy.size.height
)
.rotationEffect(.degrees(fakeLandscape ? -90 : 0))
.frame(width: proxy.size.width, height: proxy.size.height)
}
.background(Color.black)
.ignoresSafeArea()
.statusBarHidden(true)
.sheet(item: $explanationSentence) { sentence in
SentenceExplanationSheet(
mediaId: controller.currentItem?.id ?? "",
courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence,
sentences: controller.currentItem?.sentences ?? []
)
}
}
private var playerChrome: some View {
ZStack {
Color.black.ignoresSafeArea()
PlayerSurfaceView(controller: controller) { isSwipeUp in
@@ -31,17 +59,51 @@ struct FullscreenPlayerView: View {
Spacer()
if subtitleVisible {
VStack(alignment: .leading, spacing: 6) {
if showsLessonTitle {
Text(controller.currentItem?.title ?? "未选择课程")
.font(.system(size: 17, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
}
Text(sentenceMetaText)
.font(.system(size: 13))
.foregroundColor(Theme.textOverlay)
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text(sentenceTextText)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(2)
if let chineseSubtitleText = chineseSubtitleText {
Text(chineseSubtitleText)
.font(.system(size: 14))
.foregroundColor(Theme.textOverlay)
.lineLimit(2)
}
}
Spacer()
Button {
guard let sentence = controller.currentSentence,
!(sentence.text ?? "").isEmpty else {
return
}
controller.pause()
explanationSentence = sentence
} label: {
Image(systemName: "lightbulb.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundColor(
controller.currentSentence?.text?.isEmpty == false
? Theme.accentLight
: Theme.textSubtle
)
.frame(width: 30, height: 30)
.background(Theme.overlay)
.clipShape(Circle())
.accessibilityLabel("AI 讲解这句话")
}
.buttonStyle(.plain)
}
progressRow
Text(statusText)
.font(.system(size: 13))
@@ -140,10 +202,10 @@ struct FullscreenPlayerView: View {
case .loading(let isLocal):
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
case .failed:
return controller.currentItem?.title ?? "未选择课程"
return ""
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程"
return ""
}
if let text = sentence.text, !text.isEmpty {
return text
@@ -152,7 +214,24 @@ struct FullscreenPlayerView: View {
}
}
private var chineseSubtitleText: String? {
guard showChineseSubtitles else {
return nil
}
if let zh = controller.currentSentence?.zhText?.trimmingCharacters(in: .whitespacesAndNewlines),
!zh.isEmpty {
return zh
}
if (controller.currentSentence?.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return nil
}
return chineseSubtitlePlaceholder
}
private var statusText: String {
if let message = controller.cacheState.statusMessage {
return message
}
if let message = controller.transientMessage {
return message
}

View File

@@ -6,10 +6,14 @@ struct PlayerSectionView: View {
var showsContinuousToggle: Bool
@Binding var continuousPlayback: Bool
var showsSubtitle: Binding<Bool>? = .constant(true)
var showsLessonTitle: Bool = true
var showChineseSubtitles: Binding<Bool>? = nil
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
var onFullscreen: () -> Void
@State private var progressDragging = false
@State private var dragPositionMs: Int64 = 0
@State private var explanationSentence: SentenceBoundary?
var body: some View {
VStack(spacing: 0) {
@@ -19,6 +23,14 @@ struct PlayerSectionView: View {
}
}
.padding(.horizontal, 16)
.sheet(item: $explanationSentence) { sentence in
SentenceExplanationSheet(
mediaId: controller.currentItem?.id ?? "",
courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence,
sentences: controller.currentItem?.sentences ?? []
)
}
}
private var playerSurface: some View {
@@ -66,10 +78,12 @@ struct PlayerSectionView: View {
private var sentencePanel: some View {
VStack(alignment: .leading, spacing: 6) {
if showsLessonTitle {
Text(controller.currentItem?.title ?? "未选择课程")
.font(.system(size: 17, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
}
if showsContinuousToggle {
HStack(spacing: 0) {
Text("连续播放")
@@ -82,13 +96,56 @@ struct PlayerSectionView: View {
}
.padding(.vertical, 2)
}
if let showChineseSubtitles = showChineseSubtitles {
HStack(spacing: 0) {
Text("中文字幕")
.font(.system(size: 13, weight: .bold))
.foregroundColor(Theme.textSubtle)
Spacer()
Toggle("", isOn: showChineseSubtitles)
.labelsHidden()
.tint(Theme.accent)
}
.padding(.vertical, 2)
}
Text(sentenceMetaText)
.font(.system(size: 13))
.foregroundColor(Theme.textSubtle)
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text(sentenceTextText)
.font(.system(size: 15))
.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
Text(statusText)
.font(.system(size: 13))
@@ -159,10 +216,10 @@ struct PlayerSectionView: View {
}
return controller.currentItem?.title ?? "加载中"
case .failed:
return controller.currentItem?.title ?? "未选择课程"
return ""
case .idle, .loaded:
guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程"
return ""
}
if let text = sentence.text, !text.isEmpty {
return text
@@ -171,7 +228,24 @@ struct PlayerSectionView: View {
}
}
private var chineseSubtitleText: String? {
guard showChineseSubtitles?.wrappedValue == true else {
return nil
}
if let zh = controller.currentSentence?.zhText?.trimmingCharacters(in: .whitespacesAndNewlines),
!zh.isEmpty {
return zh
}
if (controller.currentSentence?.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return nil
}
return chineseSubtitlePlaceholder
}
private var statusText: String {
if let message = controller.cacheState.statusMessage {
return message
}
if let message = controller.transientMessage {
return message
}
@@ -195,3 +269,144 @@ struct PlayerSectionView: View {
controller.setPlaybackSpeed(next)
}
}
struct SentenceExplanationSheet: View {
let mediaId: String
let courseTitle: String
let sentence: SentenceBoundary
var sentences: [SentenceBoundary] = []
@Environment(\.dismiss) private var dismiss
@State private var explanation = ""
@State private var errorMessage: String?
@State private var saved = false
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(sentence.text ?? "")
.font(.system(size: 18, weight: .bold))
.foregroundColor(Theme.textDark)
if let errorMessage {
Text(errorMessage)
.font(.system(size: 15))
.foregroundColor(Theme.danger)
} else if explanation.isEmpty {
Text("AI 正在讲解这句话…")
.font(.system(size: 15))
.foregroundColor(Theme.textSubtle)
} else {
MarkdownExplanationText(text: explanation)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
.background(Color.white.ignoresSafeArea())
.navigationTitle("句子讲解")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 6) {
Button(saved ? "已保存" : "保存") {
saveExplanation()
}
.disabled(saved || explanation.isEmpty || errorMessage != nil)
Text(saved ? "(已保存本地)" : "(下次直接看本地)")
.font(.system(size: 12))
.foregroundColor(Theme.textMuted)
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(Theme.textMuted)
}
.accessibilityLabel("关闭句子讲解")
}
}
}
}
.task {
SentenceExplanationStore.shared.sync(mediaId: mediaId, sentences: sentences)
if let cached = SentenceExplanationStore.shared.load(
mediaId: mediaId,
sentenceIndex: sentence.index
) {
explanation = cached
saved = true
return
}
guard explanation.isEmpty, errorMessage == nil else { return }
do {
explanation = try await SentenceBoundaryAPI.explain(
sentence,
courseTitle: courseTitle
)
} catch {
errorMessage = "讲解失败:\(error.localizedDescription)"
}
}
}
private func saveExplanation() {
guard !saved, !explanation.isEmpty, errorMessage == nil else { return }
saved = SentenceExplanationStore.shared.save(
mediaId: mediaId,
sentenceIndex: sentence.index,
explanation: explanation
)
}
}
private struct MarkdownExplanationText: View {
let text: String
var body: some View {
Text(Self.attributed(from: text))
.font(.system(size: 16))
.foregroundColor(Theme.textDark)
.tint(Theme.buttonBlue)
.lineSpacing(5)
.frame(maxWidth: .infinity, alignment: .leading)
}
private static func attributed(from source: String) -> AttributedString {
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
)
if let parsed = try? AttributedString(
markdown: preservingLineBreaks(source),
options: options
) {
return parsed
}
return AttributedString(source)
}
private static func preservingLineBreaks(_ source: String) -> String {
let normalized = source
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var result = ""
var inFence = false
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false)
for (index, rawLine) in lines.enumerated() {
let line = String(rawLine)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("```") {
inFence.toggle()
}
result += line
guard index < lines.count - 1 else { continue }
if inFence || trimmed.isEmpty {
result += "\n"
} else {
result += "\n\n"
}
}
return result
}
}

View File

@@ -3,33 +3,86 @@ import UIKit
import UniformTypeIdentifiers
struct RootView: View {
@StateObject private var auth = AuthManager()
@StateObject private var controller = PlayerController()
@StateObject private var catalogVM = CatalogViewModel()
@State private var activeModule: Module = .train
@StateObject private var historyManager = TrainingHistoryManager()
@State private var activeModule: Module = .course
@State private var continuousPlayback = false
@State private var showChineseSubtitles = false
@State private var chineseTranslationLoading = false
@State private var chineseTranslationError = ""
@State private var chineseTranslationTask: Task<Void, Never>?
@State private var showImporter = false
@State private var showFullscreenPlayer = false
@State private var courses: [Course] = []
@State private var currentCourse: Course?
@State private var courseStatusText = "正在同步"
@State private var isRefreshingCourses = false
@State private var showCourseGuardToast = false
@State private var managedResults: [UserResultSummary] = []
@State private var managedDubShares: [UserDubShareSummary] = []
@State private var managementStatus = ""
@State private var managementIsLoading = false
@State private var deletingHistoryID: String?
enum Module {
case course
case train
case test
case mine
}
var body: some View {
if auth.isLoggedIn {
mainContent
} else {
AuthView(auth: auth) {
Task { await loadCourses() }
}
}
}
private var mainContent: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(spacing: 0) {
header
moduleTabs
if activeModule == .train {
switch activeModule {
case .course:
coursePage
case .train:
TrainingView(
controller: controller,
catalogVM: catalogVM,
continuousPlayback: $continuousPlayback,
showChineseSubtitles: $showChineseSubtitles,
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
onFullscreen: enterFullscreen
)
} else {
TestView(controller: controller, onFullscreen: enterFullscreen)
case .test:
TestView(
controller: controller,
historyManager: historyManager,
onFullscreen: enterFullscreen
)
case .mine:
minePage
}
}
if showCourseGuardToast {
VStack {
Spacer()
Text("请先选择课程")
.font(.system(size: 14))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Theme.overlay)
.clipShape(RoundedRectangle(cornerRadius: 8))
Spacer().frame(height: 80)
}
}
}
@@ -37,10 +90,31 @@ struct RootView: View {
.onAppear {
catalogVM.controller = controller
controller.setContinuousPlayback(continuousPlayback)
if auth.isLoggedIn && courses.isEmpty {
Task { await loadCourses() }
}
}
.onChange(of: showChineseSubtitles) { enabled in
if enabled {
ensureChineseTranslations()
} else {
chineseTranslationTask?.cancel()
chineseTranslationLoading = false
}
}
.onChange(of: controller.currentItem?.id) { _ in
ensureChineseTranslations()
}
.onChange(of: controller.currentItem?.sentences.count) { _ in
ensureChineseTranslations()
}
.onChange(of: activeModule) { module in
if module == .train {
controller.setContinuousPlayback(continuousPlayback)
ensureChineseTranslations()
} else if module == .test {
// 与安卓测试页一致:按当前句窗口播放,避免滑进下一句。
controller.setContinuousPlayback(false)
} else {
controller.setContinuousPlayback(true)
}
@@ -56,11 +130,21 @@ struct RootView: View {
}
}
}
.onReceive(catalogVM.$activeVideoHash) { videoHash in
syncCurrentCourse(with: videoHash)
}
.fullScreenCover(isPresented: $showFullscreenPlayer) {
FullscreenPlayerView(controller: controller) {
FullscreenPlayerView(
controller: controller,
showsLessonTitle: activeModule != .train,
showChineseSubtitles: showChineseSubtitles,
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder
) {
exitFullscreen()
}
.onAppear(perform: enterFullscreen)
.onAppear {
AppDelegate.lockOrientation(.landscape)
}
}
}
@@ -70,18 +154,24 @@ struct RootView: View {
Text("八哥口语")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.white)
Text("外语跟读训练神器")
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
.font(.system(size: 15))
.foregroundColor(Theme.textMuted)
}
Spacer()
if activeModule == .train {
headerButton("导入") {
showImporter = true
}
headerButton("刷新") {
Task {
await catalogVM.load()
}
headerButton("刷新") {
Task { await loadCourses() }
}
headerButton("退出") {
auth.logout()
currentCourse = nil
courses = []
activeModule = .course
}
}
.padding(.horizontal, 18)
@@ -105,50 +195,495 @@ struct RootView: View {
private var moduleTabs: some View {
HStack(spacing: 0) {
moduleTab("课程", module: .course)
moduleTab("训练", module: .train)
moduleTab("测试", module: .test)
moduleTab("我的", module: .mine)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func moduleTab(_ label: String, module: Module) -> some View {
let selected = activeModule == module
return Button {
Button {
if module != .course && currentCourse == nil {
showCourseGuardToast = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
showCourseGuardToast = false
}
} else {
activeModule = module
}
} label: {
Text(label)
.font(.system(size: 15, weight: .bold))
.foregroundColor(selected ? .white : Theme.textMuted)
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(selected ? Theme.buttonBlue : Theme.surface)
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.padding(2)
}
// MARK: - Course Page
private var coursePage: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("课程首页")
.font(.system(size: 26, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
HStack(spacing: 10) {
Text("选择一门课程开始训练")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
Spacer()
Button {
Task { await loadCourses() }
} label: {
Text(isRefreshingCourses ? "刷新中" : "刷新课程")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.frame(height: 32)
.background(Theme.buttonBlue)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(isRefreshingCourses)
.opacity(isRefreshingCourses ? 0.6 : 1)
.padding(.trailing, 16)
}
.padding(.horizontal, 16)
.padding(.top, 4)
Text(courseStatusText)
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
.padding(.top, 8)
if courses.isEmpty {
Text("暂无课程")
.font(.system(size: 14))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
.padding(.top, 20)
} else {
LazyVStack(spacing: 10) {
ForEach(courses) { course in
courseCard(course)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
}
.padding(.top, 12)
}
}
private func courseCard(_ course: Course) -> some View {
let isSelected = currentCourse?.videoHash == course.videoHash
return Button {
selectCourse(course)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(course.title)
.font(.system(size: 16, weight: .bold))
.foregroundColor(isSelected ? Theme.textDark : .white)
.lineLimit(2)
Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")")
.font(.system(size: 13))
.foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(isSelected ? Theme.selected : Theme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
private func selectCourse(_ course: Course) {
currentCourse = course
let video = course.toVideoSummary()
catalogVM.select(video)
activeModule = .train
}
/// 训练页的课程卡片直接操作 CatalogViewModel;
/// 这里监听它的 activeVideoHash,保证返回课程页时高亮和“当前课程”同步。
private func syncCurrentCourse(with videoHash: String?) {
guard let videoHash else { return }
if let course = courses.first(where: { $0.videoHash == videoHash }) {
currentCourse = course
return
}
// 本地导入视频不在 /courses 列表里;仍建立当前课程信息,避免课程页状态回退。
if let video = catalogVM.videos.first(where: { $0.videoHash == videoHash }) {
currentCourse = Course(
videoHash: video.videoHash,
title: video.title,
durationMs: video.durationMs,
language: video.language,
sentenceCount: video.sentenceCount,
streamUrl: video.streamUrl,
enrolled: false
)
}
}
// MARK: - Mine Page
private var minePage: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname)
.font(.system(size: 26, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.top, 12)
if let course = currentCourse {
Text("当前课程:\(course.title)")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
}
Text("此页面显示当前课程的测试记录和配音分享")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
HStack(spacing: 10) {
Button {
Task { await loadManagedHistory() }
} label: {
Text(managementIsLoading ? "刷新中..." : "刷新记录")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 14)
.frame(height: 36)
.background(Theme.buttonBlue)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(managementIsLoading)
if !managementStatus.isEmpty {
Text(managementStatus)
.font(.system(size: 12))
.foregroundColor(Theme.textMuted)
}
}
.padding(.horizontal, 16)
historyGroup(title: "我的测试结果", isEmpty: managedResults.isEmpty) {
ForEach(managedResults) { row in
resultRow(row)
}
}
historyGroup(title: "我的配音分享", isEmpty: managedDubShares.isEmpty) {
ForEach(managedDubShares) { row in
dubShareRow(row)
}
}
}
.padding(.bottom, 24)
}
.task(id: currentCourse?.videoHash) {
await loadManagedHistory()
}
}
private func historyGroup<Content: View>(
title: String,
isEmpty: Bool,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
if isEmpty {
Text("该课程暂无记录")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
} else {
LazyVStack(spacing: 8, content: content)
.padding(.horizontal, 16)
}
}
}
private func resultRow(_ row: UserResultSummary) -> some View {
historyRow(
title: "第 \(row.sentenceIndex + 1) 句 · \(String(format: "%.0f", row.overallScore)) 分",
subtitle: "\(row.passed ? "通过" : "未通过") · \(displayDate(row.createdAt))\n\(row.sentenceText)",
isDeleting: deletingHistoryID == row.id,
deleteLabel: "删除"
) {
await deleteResult(row)
}
}
private func dubShareRow(_ row: UserDubShareSummary) -> some View {
historyRow(
title: row.title,
subtitle: "\(row.segmentCount) 句 · 平均 \(String(format: "%.0f", row.averageScore)) 分 · \(displayDate(row.createdAt))",
isDeleting: deletingHistoryID == row.id,
deleteLabel: "删除"
) {
await deleteDubShare(row)
}
}
private func historyRow(
title: String,
subtitle: String,
isDeleting: Bool,
deleteLabel: String,
action: @escaping () async -> Void
) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 15, weight: .bold))
.foregroundColor(Theme.textDark)
Text(subtitle)
.font(.system(size: 12))
.foregroundColor(Theme.textSubtle)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button {
Task { await action() }
} label: {
Text(isDeleting ? "删除中..." : deleteLabel)
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.frame(height: 28)
.background(isDeleting ? Theme.border : Theme.danger)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
.disabled(isDeleting)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Theme.lightBorder, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private func loadManagedHistory() async {
guard let course = currentCourse else {
managementStatus = "请先选择课程"
managedResults = []
managedDubShares = []
return
}
managementIsLoading = true
managementStatus = "正在加载..."
defer { managementIsLoading = false }
do {
async let resultsTask = UserHistoryAPI.results(token: auth.token)
async let sharesTask = UserHistoryAPI.dubShares(token: auth.token)
let (allResults, allShares) = try await (resultsTask, sharesTask)
managedResults = allResults.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
managedDubShares = allShares.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
managementStatus = "共 \(managedResults.count) 条测试、\(managedDubShares.count) 条配音"
} catch {
managementStatus = "记录加载失败:\(error.localizedDescription)"
}
}
private func deleteResult(_ row: UserResultSummary) async {
guard let course = currentCourse, deletingHistoryID == nil else { return }
deletingHistoryID = row.id
defer { deletingHistoryID = nil }
do {
try await UserHistoryAPI.deleteResult(token: auth.token, attemptId: row.attemptId)
managedResults.removeAll { $0.id == row.id }
historyManager.store.removeScore(videoHash: course.videoHash, sentenceIndex: row.sentenceIndex)
historyManager.notifyHistoryChanged()
managementStatus = "测试记录已删除"
} catch {
managementStatus = "删除失败:\(error.localizedDescription)"
}
}
private func deleteDubShare(_ row: UserDubShareSummary) async {
guard let course = currentCourse, deletingHistoryID == nil else { return }
deletingHistoryID = row.id
defer { deletingHistoryID = nil }
do {
try await UserHistoryAPI.deleteDubShare(token: auth.token, shareId: row.shareId)
managedDubShares.removeAll { $0.id == row.id }
historyManager.store.removeAllAudio(videoHash: course.videoHash)
historyManager.notifyHistoryChanged()
managementStatus = "配音分享已删除"
} catch {
managementStatus = "删除失败:\(error.localizedDescription)"
}
}
private func displayDate(_ value: String) -> String {
value.replacingOccurrences(of: "T", with: " ").prefix(16).description
}
// MARK: - Load Courses
private func loadCourses() async {
guard !isRefreshingCourses else { return }
isRefreshingCourses = true
defer { isRefreshingCourses = false }
courseStatusText = "正在同步"
do {
var fetched = try await AuthAPI.courses(token: auth.token)
var boundarySucceeded = 0
var boundaryFailed = 0
courses = fetched
if !fetched.isEmpty {
courseStatusText = "\(fetched.count) 个课程,正在刷新句界"
let refreshed = await catalogVM.preloadBoundaries(
for: fetched.map { $0.toVideoSummary() }
)
boundarySucceeded = refreshed.succeeded
boundaryFailed = refreshed.failed
let refreshedByHash = Dictionary(uniqueKeysWithValues: refreshed.videos.map { ($0.videoHash, $0) })
fetched = fetched.map { course in
guard let video = refreshedByHash[course.videoHash] else { return course }
return Course(
videoHash: course.videoHash,
title: course.title,
durationMs: video.durationMs,
language: course.language,
sentenceCount: video.sentenceCount,
streamUrl: course.streamUrl,
enrolled: course.enrolled
)
}
if currentCourse != nil,
let video = refreshed.videos.first(where: { $0.videoHash == currentCourse?.videoHash }) {
currentCourse = fetched.first(where: { $0.videoHash == video.videoHash })
}
}
courses = fetched
if fetched.isEmpty {
courseStatusText = "0 个课程"
} else if boundaryFailed == 0 {
courseStatusText = "\(fetched.count) 个课程 · 句界已刷新"
} else if boundarySucceeded == 0 {
courseStatusText = "\(fetched.count) 个课程 · 句界刷新失败"
} else {
courseStatusText = "\(fetched.count) 个课程 · 句界部分刷新(失败 \(boundaryFailed))"
}
} catch {
courseStatusText = "课程加载失败:\(error.localizedDescription)"
}
}
// MARK: - Fullscreen
private func enterFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
updateSupportedInterfaceOrientations()
AppDelegate.lockOrientation(.landscape)
showFullscreenPlayer = true
}
private func exitFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
updateSupportedInterfaceOrientations()
let restored: UIInterfaceOrientationMask =
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
AppDelegate.lockOrientation(restored)
showFullscreenPlayer = false
}
private func updateSupportedInterfaceOrientations() {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let controllers = scenes.flatMap { $0.windows }.compactMap(\.rootViewController)
for controller in controllers {
if #available(iOS 16.0, *) {
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
UIViewController.attemptRotationToDeviceOrientation()
private var chineseSubtitlePlaceholder: String {
if chineseTranslationLoading {
return "正在翻译中文字幕…"
}
if !chineseTranslationError.isEmpty {
return chineseTranslationError
}
return "暂无中文字幕"
}
@MainActor
private func applyChineseTranslations(_ map: [Int: String], mediaId: String) {
guard controller.currentItem?.id == mediaId else { return }
let updated = (controller.currentItem?.sentences ?? []).map { sentence in
sentence.withZhText(map[sentence.index] ?? sentence.zhText)
}
controller.updateBoundaries(updated)
}
private func ensureChineseTranslations() {
guard showChineseSubtitles, activeModule == .train else {
return
}
guard let item = controller.currentItem else {
return
}
let needsTranslation = item.sentences.contains {
!($0.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
($0.zhText ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
guard needsTranslation else {
chineseTranslationLoading = false
chineseTranslationError = ""
return
}
chineseTranslationTask?.cancel()
chineseTranslationLoading = true
chineseTranslationError = ""
let mediaId = item.id
chineseTranslationTask = Task {
do {
let expectedIndexes = item.sentences.compactMap { sentence -> Int? in
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : sentence.index
}
let map = try await SentenceBoundaryAPI.loadSubtitles(
videoHash: mediaId,
expectedIndexes: expectedIndexes
) { partial in
Task { @MainActor in
applyChineseTranslations(partial, mediaId: mediaId)
}
}
guard !Task.isCancelled else { return }
await MainActor.run {
applyChineseTranslations(map, mediaId: mediaId)
chineseTranslationLoading = false
chineseTranslationError = ""
controller.showTransientMessage("已加载中文字幕")
}
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
chineseTranslationLoading = false
chineseTranslationError = "中文字幕翻译失败"
controller.showTransientMessage("中文字幕翻译失败:\(error.localizedDescription)")
}
}
}
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct TestView: View {
@ObservedObject var controller: PlayerController
@ObservedObject var historyManager: TrainingHistoryManager
let onFullscreen: () -> Void
@State private var recordingSession: AudioRecorderSession?
@@ -25,6 +26,8 @@ struct TestView: View {
@State private var showShareSheet = false
@State private var dubPlaybackObserver: NSObjectProtocol?
@State private var testSubtitleVisible = true
@State private var loadedCourseHash: String?
@State private var remoteHistoryGeneration = 0
var body: some View {
ScrollView(showsIndicators: false) {
@@ -32,7 +35,7 @@ struct TestView: View {
PlayerSectionView(
controller: controller,
showsContinuousToggle: false,
continuousPlayback: .constant(true),
continuousPlayback: .constant(false),
showsSubtitle: $testSubtitleVisible,
onFullscreen: onFullscreen
)
@@ -58,13 +61,16 @@ struct TestView: View {
}
HStack(spacing: 8) {
sentenceButton("上一句") {
stopDubPlayback(message: nil)
controller.seekToPreviousSentence()
}
sentenceButton("播放原句") {
stopDubPlayback(message: nil)
controller.playCurrentSentenceAndStop()
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
}
sentenceButton("下一句") {
stopDubPlayback(message: nil)
controller.seekToNextSentence()
}
}
@@ -80,19 +86,24 @@ struct TestView: View {
.buttonStyle(.plain)
.disabled(isAssessing)
HStack(spacing: 8) {
dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
dubButton(
dubbingPlayback ? "停止播放" : "播放本句录音",
enabled: dubbingPlayback || (controller.currentSentence.flatMap { dubSegments[$0.index] } != nil)
) {
toggleDubPlayback()
}
dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
stopDubPlayback(message: nil)
generateDubShareAudio()
}
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
stopDubPlayback(message: nil)
shareMergedDub()
}
}
Text(dubbingStatus)
.font(.system(size: 13))
.foregroundColor(Theme.accentDeep)
.foregroundColor(isSharingDub ? Theme.danger : Theme.accentDeep)
.frame(maxWidth: .infinity, alignment: .leading)
Text(scoreSummary)
.font(.system(size: 22, weight: .bold))
@@ -113,7 +124,8 @@ struct TestView: View {
.padding(16)
}
}
.onReceive(controller.$currentItem) { _ in
.onReceive(controller.$currentItem) { item in
synchronizeHistory(for: item?.id)
refreshTestUI()
}
.onReceive(controller.$currentSentence) { _ in
@@ -122,6 +134,14 @@ struct TestView: View {
.onDisappear {
stopDubPlayback(message: nil)
}
.onAppear {
synchronizeHistory(for: controller.currentItem?.id)
refreshTestUI()
}
.onChange(of: historyManager.historyVersion) { _ in
synchronizeHistory(for: controller.currentItem?.id, forceLocalReload: true)
refreshTestUI()
}
.sheet(isPresented: $showShareSheet) {
if let url = shareURL {
ActivityShareSheet(items: [url])
@@ -159,6 +179,7 @@ struct TestView: View {
// MARK: - 录音
private func toggleRecording() {
stopDubPlayback(message: nil)
if isRecording {
stopRecordingAndAssess()
} else {
@@ -281,16 +302,26 @@ struct TestView: View {
try? FileManager.default.removeItem(at: rawURL)
return
}
if let oldURL = dubSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldURL)
var audioURL = url
var rawPersistedURL = rawURL
if let item = controller.currentItem {
do {
let persisted = try historyManager.store.saveSegment(
videoHash: item.id,
sentenceIndex: sentence.index,
audioURL: url,
rawURL: rawURL
)
audioURL = persisted.audioURL
rawPersistedURL = persisted.rawURL
} catch {
testStatus = "录音已评测,但本地历史保存失败:\(error.localizedDescription)"
}
if let oldRawURL = dubRawSegments[sentence.index] {
try? FileManager.default.removeItem(at: oldRawURL)
}
dubSegments[sentence.index] = url
dubRawSegments[sentence.index] = rawURL
dubSegments[sentence.index] = audioURL
dubRawSegments[sentence.index] = rawPersistedURL
mergedDubURL = nil
submitAssessment(recordingURL: url)
submitAssessment(recordingURL: audioURL)
}
private func restorePlaybackAudioSession() {
@@ -328,6 +359,8 @@ struct TestView: View {
Task {
do {
let result = try await ImitationAssessor.assess(request: request)
latestScores[sentence.index] = result
historyManager.store.saveScores(videoHash: item.id, scores: latestScores)
applyAssessmentResult(result)
} catch {
testStatus = "评测失败:\(error.localizedDescription)"
@@ -382,44 +415,197 @@ struct TestView: View {
testStatus = "请先在训练模块选择一个云端课程"
return
}
if let score = latestScores[sentence.index] {
applyAssessmentResult(score)
} else {
let currentRecording = dubSegments[sentence.index]
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
if mergedDubURL == nil {
scoreSummary = "尚未评测"
scoreDetail = ""
}
if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub {
dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音"
}
}
// MARK: - 历史记录
private func synchronizeHistory(for videoHash: String?, forceLocalReload: Bool = false) {
guard let videoHash else {
loadedCourseHash = nil
dubSegments = [:]
dubRawSegments = [:]
latestScores = [:]
return
}
let normalizedHash = historyManager.store.normalizedVideoHash(videoHash)
guard loadedCourseHash != normalizedHash || forceLocalReload else { return }
loadedCourseHash = normalizedHash
remoteHistoryGeneration += 1
let generation = remoteHistoryGeneration
let snapshot = historyManager.store.load(videoHash: videoHash)
dubSegments = snapshot.audioSegments
dubRawSegments = snapshot.rawSegments
for (index, audioURL) in snapshot.audioSegments where dubRawSegments[index] == nil {
dubRawSegments[index] = audioURL
}
latestScores = snapshot.scores
mergedDubURL = nil
if snapshot.audioSegments.isEmpty && snapshot.scores.isEmpty {
dubbingStatus = "正在同步历史配音…"
} else {
dubbingStatus = "已加载历史配音 \(snapshot.audioSegments.count) 句、评分 \(snapshot.scores.count) 句"
}
Task {
await restoreRemoteHistory(videoHash: videoHash, generation: generation)
}
}
private func restoreRemoteHistory(videoHash: String, generation: Int) async {
let token = AppConfig.authToken
guard !token.isEmpty else { return }
let normalizedHash = historyManager.store.normalizedVideoHash(videoHash)
do {
let rows = try await UserHistoryAPI.results(token: token)
guard remoteHistoryGeneration == generation,
loadedCourseHash == normalizedHash else { return }
var latest = [Int: UserResultSummary]()
rows.filter { historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash }
.sorted { $0.createdAt < $1.createdAt }
.forEach { latest[$0.sentenceIndex] = $0 }
var addedScores = 0
for (index, row) in latest where latestScores[index] == nil {
if let score = decodedScore(payload: [
"overall_score": row.overallScore,
"passed": row.passed,
"reference_text": row.sentenceText,
"feedback": "来自历史测试记录",
]) {
latestScores[index] = score
addedScores += 1
}
}
if addedScores > 0 {
historyManager.store.saveScores(videoHash: videoHash, scores: latestScores)
dubbingStatus = "已同步历史评分 \(latestScores.count) 句"
refreshTestUI()
}
} catch {
if remoteHistoryGeneration == generation, latestScores.isEmpty {
dubbingStatus = "历史评分同步失败:\(error.localizedDescription)"
}
}
guard dubSegments.isEmpty else { return }
do {
let shares = try await UserHistoryAPI.dubShares(token: token)
guard remoteHistoryGeneration == generation,
loadedCourseHash == normalizedHash else { return }
guard let share = shares
.filter({ historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash })
.max(by: { $0.createdAt < $1.createdAt }) else {
if dubSegments.isEmpty && latestScores.isEmpty {
dubbingStatus = "暂无可用的历史配音音频"
}
return
}
let detail = try await UserHistoryAPI.dubShareDetail(token: token, shareId: share.shareId)
var restored = [Int: URL]()
for segment in detail.segments {
guard remoteHistoryGeneration == generation else { return }
let filename = segment.audioURL.split(separator: "/").last.map(String.init) ?? ""
guard !filename.isEmpty else { continue }
let destination = historyManager.store.audioDestination(
videoHash: videoHash,
sentenceIndex: segment.index
)
try await UserHistoryAPI.downloadAudio(
token: token,
audioPath: "api/v1/dub-shares/\(share.shareId)/audio/\(filename)",
destination: destination
)
restored[segment.index] = destination
var payload: [String: Any] = [
"overall_score": segment.overallScore,
"feedback": "来自历史配音分享",
]
if !segment.recognizedText.isEmpty {
payload["recognized_text"] = segment.recognizedText
}
segment.scoreDetails.forEach { payload[$0.key] = $0.value }
if let score = decodedScore(payload: payload), latestScores[segment.index] == nil {
latestScores[segment.index] = score
}
}
guard remoteHistoryGeneration == generation,
loadedCourseHash == normalizedHash else { return }
for (index, audioURL) in restored where dubSegments[index] == nil {
dubSegments[index] = audioURL
dubRawSegments[index] = audioURL
}
historyManager.store.saveScores(videoHash: videoHash, scores: latestScores)
if !restored.isEmpty {
dubbingStatus = "已从云端恢复配音 \(restored.count) 句"
} else if dubSegments.isEmpty && latestScores.isEmpty {
dubbingStatus = "暂无可用的历史配音音频"
}
refreshTestUI()
} catch {
guard remoteHistoryGeneration == generation, dubSegments.isEmpty else { return }
dubbingStatus = "历史配音同步失败:\(error.localizedDescription)"
}
}
private func decodedScore(payload: [String: Any]) -> ImitationAssessmentResult? {
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
return try? JSONDecoder().decode(ImitationAssessmentResult.self, from: data)
}
// MARK: - 配音
private func toggleDubPlayback() {
if dubbingPlayback {
stopDubPlayback(message: "已停止配音播放")
stopDubPlayback(message: "已停止录音播放")
return
}
let sentence = controller.currentSentence
let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] }
guard let audioURL else { return }
if mergedDubURL == nil, let sentence = sentence {
controller.seek(to: sentence.startMs)
guard let sentence = controller.currentSentence else {
dubbingStatus = "请先选择要播放的句子"
return
}
guard let audioURL = dubSegments[sentence.index] else {
dubbingStatus = "当前句还没有录音,请先录音后再播放"
return
}
// Always play the current sentence recording only (never the merged full track).
stopDubPlayback(message: nil)
controller.seek(to: sentence.startMs)
controller.pause()
controller.setVolume(0)
let player = dubPlayer ?? AVPlayer()
dubPlayer = player
player.replaceCurrentItem(with: AVPlayerItem(url: audioURL))
let item = AVPlayerItem(url: audioURL)
player.replaceCurrentItem(with: item)
dubbingPlayback = true
dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音"
dubbingStatus = "正在播放当前句录音"
player.play()
if dubPlaybackObserver == nil {
if let dubPlaybackObserver {
NotificationCenter.default.removeObserver(dubPlaybackObserver)
self.dubPlaybackObserver = nil
}
dubPlaybackObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: player.currentItem,
object: item,
queue: .main
) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
stopDubPlayback(message: "配音播放完成")
}
stopDubPlayback(message: "本句录音播放完成")
}
}
}
@@ -468,7 +654,7 @@ struct TestView: View {
return
}
isSharingDub = true
dubbingStatus = "正在生成分享链接…"
dubbingStatus = "正在生成分享链接,请稍候..."
let request = DubShareUploadRequest(
videoHash: item.id,
title: item.title,

View File

@@ -17,6 +17,7 @@ enum Theme {
static let textDark = Color(hex: 0x12181F)
static let textMuted = Color(hex: 0x97A2AE)
static let textSubtle = Color(hex: 0x58626C)
static let danger = Color(hex: 0xDC2626)
static let textOverlay = Color(hex: 0xA3ACB6)
static func formatTime(_ ms: Int64) -> String {

View File

@@ -4,6 +4,8 @@ struct TrainingView: View {
@ObservedObject var controller: PlayerController
@ObservedObject var catalogVM: CatalogViewModel
@Binding var continuousPlayback: Bool
@Binding var showChineseSubtitles: Bool
var chineseSubtitlePlaceholder: String = "正在翻译中文字幕…"
let onFullscreen: () -> Void
var body: some View {
@@ -13,6 +15,9 @@ struct TrainingView: View {
controller: controller,
showsContinuousToggle: true,
continuousPlayback: $continuousPlayback,
showsLessonTitle: false,
showChineseSubtitles: $showChineseSubtitles,
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
onFullscreen: onFullscreen
)
CatalogView(vm: catalogVM)
@@ -27,5 +32,8 @@ struct TrainingView: View {
: "已关闭连续播放,将循环播放当前句子"
)
}
.onChange(of: showChineseSubtitles) { enabled in
controller.showTransientMessage(enabled ? "已开启中文字幕" : "已关闭中文字幕")
}
}
}

View File

@@ -3,7 +3,8 @@
iOS 端与 [android/](../android/README.md) 示例 App 功能对齐的移动端:
- 在线视频目录(`GET /api/v1/videos`,仅展示 `ready` 课程,自动解析相对流地址)
- AVPlayer 在线播放 + 本地缓存导入;单击播放/暂停
- 云端视频先完整下载为本地整文件缓存,下载完成后才允许 AVPlayer 播放;
支持本地缓存导入;单击播放/暂停
- 左划 / 右划:上一句 / 下一句(无句边界时回退为 10 秒快退 / 快进)
- 长按播放区:按触摸点水平位置变速(左慢右快,0.5x - 2.0x),松手恢复原速
- 按句跳转:句边界来自 `GET /api/v1/videos/{sha256}/sentence-boundaries`

File diff suppressed because it is too large Load Diff

468
moss/README.md Normal file
View 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
```

View 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

View 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
View 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"

View File

@@ -1,4 +1,5 @@
av>=12.0
requests>=2.32.0
sounddevice>=0.4.6
numpy>=1.26
Pillow>=10.0

View 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);
});

View File

@@ -9,6 +9,7 @@ MAX_VIDEO_UPLOAD_BYTES=12884901888
MAX_ATTEMPT_UPLOAD_BYTES=52428800
KEEP_ATTEMPT_AUDIO=false
ASSESSMENT_PASS_SCORE=70
LOG_LEVEL=INFO
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
MOSS_MODEL=OpenMOSS-Team/MOSS-Transcribe-Diarize
@@ -17,6 +18,16 @@ MOSS_MAX_NEW_TOKENS=65536
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
MOSS_END_PADDING_MS=300
# AI sentence explanation (OpenAI-compatible chat endpoint). A base URL ending
# in /v1 is also accepted and will automatically use /v1/chat/completions.
# The URL and key stay on the server; mobile clients only send the sentence text.
AI_API_URL=https://api.example.com/v1/chat/completions
AI_API_KEY=replace-with-your-ai-api-key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=45
# 后台批量翻译可使用更长时间;实际翻译超时 = max(AI_TIMEOUT_SECONDS, AI_TRANSLATION_TIMEOUT_SECONDS)
AI_TRANSLATION_TIMEOUT_SECONDS=120
# 火山引擎对象存储(TOS / 火山 OSS)。桶请设置为公共读,或把下方地址配置为可公开访问的 CDN 域名。
VOLCANO_OSS_ACCESS_KEY=replace-with-volcano-access-key
VOLCANO_OSS_SECRET_KEY=replace-with-volcano-secret-key

View File

@@ -5,7 +5,8 @@
- `oral-trainer-api`:视频上传、管理后台、边界查询、Range 视频播放和朗读评分。
- GPU 转写服务(二选一,只在服务器内网监听):
- `whisper/`:faster-whisper(Speaches),推荐,部署最简单,见第 2A 节。
- `MOSS-Transcribe-Diarize`:带说话人分离,见第 2 节。
- `MOSS-Transcribe-Diarize`:带说话人分离,见第 2 节和
[`moss/README.md`](../moss/README.md)。
推荐 Ubuntu 22.04/24.04、Python 3.12、FFmpeg、NVIDIA GPU 和已安装的 NVIDIA 驱动。MOSS 0.9B 的实际显存占用受推理框架、并发和音频长度影响,生产环境建议从 16 GB 以上显存开始验证。
@@ -50,6 +51,8 @@ cp -a whisper/. /opt/whisper/ # 用 whisper/. 而不是 whisper/*,否则不
cd /opt/whisper
cp .env.example .env # 按需修改 WHISPER_MODEL
# 默认从 DaoCloud 拉 ghcr 镜像;能直连 GitHub 时在 .env 里设
# WHISPER_IMAGE=ghcr.io/speaches-ai/speaches:latest-cuda
docker compose up -d
docker compose logs -f whisper # 首次启动下载模型(约 3 GB)
```
@@ -155,8 +158,9 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
`sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积,
只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束;
逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有
标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把
该句拆成多句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按
标点处断开的 segment 会自动合并成同一句。管理后台的文本保存、换行拆分占位、相邻句
合并和结束时间微调会立即入库,但不会触发翻译或转写;关闭句子对话框时才统一执行
一轮拆分时间戳处理和翻译。拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按
文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新,
并保证相邻句子不重叠。若转写服务支持词级时间戳
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
@@ -185,12 +189,42 @@ openssl rand -hex 32
```dotenv
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
AI_API_URL=https://api.example.com/v1/chat/completions
AI_API_KEY=replace-with-your-ai-api-key
AI_MODEL=gpt-4o-mini
```
若使用 Whisper 方案(第 2A 节),把 `MOSS_TRANSCRIBE_URL` 改为
`http://127.0.0.1:9000/v1/audio/transcriptions`,并把 `MOSS_MODEL` 设为
`Systran/faster-whisper-large-v3`。
Dockerfile 默认使用国内镜像源,避免构建时从 Docker Hub / Debian / PyPI 直连过慢:
- 基础镜像:`docker.m.daocloud.io/library/python:3.12-slim`
- apt:`mirrors.aliyun.com`
- pip:清华 `https://pypi.tuna.tsinghua.edu.cn/simple`
如需覆盖,可在 `sentence_api/.env` 或构建命令前设置:
```bash
export PYTHON_IMAGE=python:3.12-slim # 能直连 Docker Hub 时
export PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple
export PIP_TRUSTED_HOST=mirrors.aliyun.com
export APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
```
拉 Docker Hub 官方镜像仍然慢时,把仓库里的 `sentence_api/docker-daemon.json.example`
拷到 Docker 配置并重启:
```bash
sudo mkdir -p /etc/docker
sudo cp sentence_api/docker-daemon.json.example /etc/docker/daemon.json
sudo systemctl restart docker
```
`registry-mirrors` 只加速 `docker.io`。Whisper 的 `ghcr.io` 镜像请用 compose 里默认的
DaoCloud 前缀(见第 2A 节)。
启动:
```bash

View File

@@ -1,10 +1,27 @@
FROM python:3.12-slim
ARG PYTHON_IMAGE=docker.m.daocloud.io/library/python:3.12-slim
FROM ${PYTHON_IMAGE}
ARG APT_MIRROR=mirrors.aliyun.com
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_INDEX_URL=${PIP_INDEX_URL} \
PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
RUN apt-get update \
RUN set -eux; \
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_MIRROR}|g" \
/etc/apt/sources.list.d/debian.sources; \
fi; \
if [ -f /etc/apt/sources.list ]; then \
sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_MIRROR}|g" \
/etc/apt/sources.list; \
fi; \
apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -12,6 +12,8 @@ FastAPI service for the Android oral-training SDK. It provides:
- video catalog and HTTP Range playback;
- sentence-boundary lookup compatible with the existing Android SDK;
- student recording upload and `asr-fluency-v1` assessment;
- signed-in user history for test results and dubbing shares, including deletion;
- AI sentence explanation for signed-in mobile users;
- the legacy JSON boundary index as a read-only fallback.
## Local Run
@@ -50,20 +52,74 @@ GET /api/v1/videos
GET /api/v1/videos/{sha256}
GET /api/v1/videos/{sha256}/content
GET /api/v1/videos/{sha256}/sentence-boundaries
POST /api/v1/audio/sentence-boundaries
POST /api/v1/sentences/explain
POST /api/v1/videos/{sha256}/sentences/{index}/assessments
POST /api/v1/admin/videos
PUT /api/v1/admin/videos/raw
POST /api/v1/admin/videos/{sha256}/process
POST /api/v1/admin/videos/{sha256}/repair-gaps
GET /api/v1/admin/videos/{sha256}/subtitle-translation
POST /api/v1/admin/videos/{sha256}/subtitle-translation
POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
PUT /api/v1/admin/videos/{sha256}/sentences/batch
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
PUT /api/v1/admin/users/password
DELETE /api/v1/admin/videos/{sha256}
```
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
Administrators can set a local password for an SSO-linked account with:
```bash
curl -X PUT https://your-server/api/v1/admin/users/password \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"username":"bobcoc","password":"new-password"}'
```
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
authenticated client, generates boundaries in memory/temp storage only, and
returns a sentence document. It does not register or archive the uploaded
audio.
## AI Sentence Explanation
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.
A base URL ending in `/v1` is also accepted and is resolved to
`/v1/chat/completions`:
```dotenv
AI_API_URL=https://api.example.com/v1/chat/completions
AI_API_KEY=replace-with-your-ai-api-key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=45
```
The endpoint requires a signed-in user and accepts only the sentence and course
context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
to the mobile clients.
Chinese captions are stored on the course in the server database and shared by
every user. `GET /api/v1/videos/{sha256}/subtitle-translation` returns the saved
captions without calling the AI, including `complete` and `status` so clients can
poll long videos. `POST` starts missing-caption generation in the background and
returns quickly; it translates in small windows with neighboring context, runs
several windows in parallel, retries and splits a window if the model response is
invalid, and writes each finished window immediately so later viewers can reuse
it. English sentence edits clear the saved Chinese captions for that course.
Desktop playback of a local file can `POST` the same endpoint with the locally
generated English cues (`duration_ms`, `algorithm_version`, `sentences`). If that
SHA-256 is not already a course, the server stores the English subtitles and
translates them; later `GET`/`POST` calls poll and reuse the saved Chinese
captions. Official course sentences are never overwritten by a local upload.
## Assessment
```bash

View File

@@ -180,8 +180,8 @@ sudo systemctl restart whisper-transcribe
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 更新后接口行为没变 | 忘了 `--build`,容器还在跑旧镜像 | 执行 `docker compose up -d --build` |
| 构建时 pip 安装超时 | 服务器访问 PyPI 受限 | 给 Dockerfile 的 pip install 加 `-i https://pypi.tuna.tsinghua.edu.cn/simple` 后重新构建 |
| 构建时拉基础镜像超时 | Docker Hub 网络问题 | 检查 `/etc/docker/daemon.json` 的 `registry-mirrors`(已配置 DaoCloud) |
| 构建时 pip 安装超时 | 服务器访问 PyPI 受限 | Dockerfile 默认已用清华 PyPI;仍慢可改 `PIP_INDEX_URL` 后 `--build` |
| 构建时拉基础镜像超时 | Docker Hub 网络问题 | Dockerfile 默认从 DaoCloud 拉 `python:3.12-slim`;也可把 `docker-daemon.json.example` 拷到 `/etc/docker/daemon.json` 后 `systemctl restart docker` |
| `healthz` 里 `moss_configured: false` | `.env` 里 `MOSS_TRANSCRIBE_URL` 为空或改了未重建 | 改 `.env` 后执行 `docker compose up -d` |
| Whisper 9000 端口连不上 | 服务未运行或容器被删 | `systemctl status whisper-transcribe`;容器被删时用 `systemctl start` 重建 |
| API 重启后视频卡在 `processing` | 重启中断了处理流程 | 后台点“重新处理”即可 |

655
sentence_api/ai_tutor.py Normal file
View 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

View File

@@ -26,6 +26,12 @@ class Settings:
moss_timeout_seconds: float
moss_max_new_tokens: int
moss_end_padding_ms: int
log_level: str
ai_api_url: str
ai_api_key: str
ai_model: str
ai_timeout_seconds: float
ai_translation_timeout_seconds: float
pass_score: float
volcano_oss_access_key: str
volcano_oss_secret_key: str
@@ -61,6 +67,14 @@ class Settings:
moss_timeout_seconds=float(os.getenv("MOSS_TIMEOUT_SECONDS", "1800")),
moss_max_new_tokens=int(os.getenv("MOSS_MAX_NEW_TOKENS", "65536")),
moss_end_padding_ms=int(os.getenv("MOSS_END_PADDING_MS", "300")),
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
ai_api_url=os.getenv("AI_API_URL", "").strip().rstrip("/"),
ai_api_key=os.getenv("AI_API_KEY", "").strip(),
ai_model=os.getenv("AI_MODEL", "gpt-4o-mini"),
ai_timeout_seconds=float(os.getenv("AI_TIMEOUT_SECONDS", "45")),
ai_translation_timeout_seconds=float(
os.getenv("AI_TRANSLATION_TIMEOUT_SECONDS", "120")
),
pass_score=float(os.getenv("ASSESSMENT_PASS_SCORE", "70")),
volcano_oss_access_key=os.getenv("VOLCANO_OSS_ACCESS_KEY", "").strip(),
volcano_oss_secret_key=os.getenv("VOLCANO_OSS_SECRET_KEY", "").strip(),

View File

@@ -3,6 +3,11 @@ services:
build:
context: ..
dockerfile: sentence_api/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-docker.m.daocloud.io/library/python:3.12-slim}
APT_MIRROR: ${APT_MIRROR:-mirrors.aliyun.com}
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-pypi.tuna.tsinghua.edu.cn}
restart: unless-stopped
network_mode: host
env_file:

View File

@@ -0,0 +1,5 @@
{
"registry-mirrors": [
"https://docker.m.daocloud.io"
]
}

View File

@@ -2,9 +2,11 @@ import hashlib
import hmac
import logging
import mimetypes
import shutil
import subprocess
import tempfile
import uuid
import urllib.request
import urllib.error
import base64
from pathlib import Path
from typing import Any, BinaryIO, Dict, List, Optional
import json
@@ -22,10 +24,13 @@ from fastapi import (
Request,
UploadFile,
)
from pydantic import ValidationError
from fastapi.responses import FileResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from .ai_tutor import AITutor, AITutorError
from .translation_jobs import SubtitleTranslationService
from .assessment import AssessmentService
from .auth import AuthenticatedUser
@@ -36,20 +41,36 @@ from .models import (
AuthRequest,
AuthResponse,
CourseSummary,
CourseTitleUpdate,
SentenceBoundaryAdjust,
SentenceGapRepairRequest,
SentenceGapRepairResponse,
SentenceBoundary,
SentenceBoundaryDocument,
SentenceExplanationRequest,
SentenceExplanationResponse,
AdminSubtitleTranslationResponse,
AdminSentenceBatchItem,
AdminSentenceBatchUpdate,
LocalSubtitleTranslationRequest,
SubtitleRetranslateRequest,
SubtitleTranslationResponse,
PasswordResetRequest,
SentenceTextUpdate,
UserDubShareSummary,
SsoExchangeRequest,
SsoExchangeResponse,
SsoBindPhoneRequest,
UserPublic,
UserResultSummary,
UserAdminListResponse,
VideoDetailResponse,
VideoListResponse,
VideoSummary,
VideoUploadResponse,
)
from .oss import VolcanoOSSUploader
from .processing import VideoProcessor, split_sentence_text
from .processing import VideoProcessor, generate_audio_boundary_document, split_sentence_text
from .repository import VideoRepository
from .store import BoundaryStore
from .transcription import MossTranscriber, Transcriber
@@ -64,6 +85,7 @@ SHA256_PATH = ApiPath(
)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
MAX_AUDIO_BOUNDARY_BYTES = 512 * 1024 * 1024
class UploadTooLargeError(ValueError):
@@ -79,6 +101,10 @@ def create_app(
oss_uploader: Optional[VolcanoOSSUploader] = None,
) -> FastAPI:
service_settings = settings or Settings.from_env()
logging.basicConfig(
level=getattr(logging, service_settings.log_level, logging.INFO),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
service_settings.ensure_directories()
video_repository = repository or VideoRepository(service_settings.database_path)
legacy_store = store or BoundaryStore(service_settings.legacy_boundaries_file)
@@ -88,6 +114,13 @@ def create_app(
timeout_seconds=service_settings.moss_timeout_seconds,
max_new_tokens=service_settings.moss_max_new_tokens,
)
ai_tutor = AITutor(
endpoint=service_settings.ai_api_url,
api_key=service_settings.ai_api_key,
model=service_settings.ai_model,
timeout_seconds=service_settings.ai_timeout_seconds,
translation_timeout_seconds=service_settings.ai_translation_timeout_seconds,
)
oss = oss_uploader or VolcanoOSSUploader(service_settings)
processor = VideoProcessor(service_settings, video_repository, moss, oss)
assessment_service = AssessmentService(
@@ -105,9 +138,14 @@ def create_app(
application.state.boundary_store = legacy_store
application.state.video_repository = video_repository
application.state.transcriber = moss
application.state.ai_tutor = ai_tutor
application.state.oss_uploader = oss
application.state.video_processor = processor
application.state.assessment_service = assessment_service
application.state.subtitle_translations = SubtitleTranslationService(
video_repository,
lambda: application.state.ai_tutor,
)
static_dir = Path(__file__).resolve().parent / "static"
if static_dir.is_dir():
@@ -156,6 +194,7 @@ def create_app(
"video_count": len(videos) + legacy_store.count(),
"managed_video_count": len(videos),
"moss_configured": moss.available,
"ai_explanation_configured": application.state.ai_tutor.available,
"volcano_oss_configured": oss.enabled,
"scoring_version": "asr-fluency-v1",
}
@@ -200,6 +239,75 @@ def create_app(
video_repository.delete_session(token)
return Response(status_code=204)
@application.post("/api/v1/auth/sso/exchange", response_model=SsoExchangeResponse)
def sso_exchange(payload: SsoExchangeRequest) -> SsoExchangeResponse:
# Decode the JWT payload from 第一课堂 (without signature verification —
# the token was obtained directly from the 第一课堂 login page in a WebView).
try:
parts = payload.access_token.split(".")
if len(parts) != 3:
raise ValueError("Invalid JWT format")
# Base64url decode the payload (second part)
padding = len(parts[1]) % 4
if padding:
parts[1] += "=" * (4 - padding)
decoded_bytes = base64.urlsafe_b64decode(parts[1])
userinfo = json.loads(decoded_bytes.decode("utf-8"))
except (ValueError, json.JSONDecodeError, IndexError) as exc:
raise HTTPException(
status_code=401,
detail=f"Failed to decode 第一课堂 JWT token: {exc}",
)
# Check if the token is expired
import time
exp = userinfo.get("exp")
if exp and time.time() > exp:
raise HTTPException(status_code=401, detail="第一课堂 token has expired.")
d1kt_username = userinfo.get("username") or ""
if not d1kt_username:
raise HTTPException(status_code=401, detail="第一课堂 token did not contain a username.")
fullname = userinfo.get("fullname") or d1kt_username
email = userinfo.get("email") or ""
# Find or create a local user with the same username.
existing = video_repository.get_user_by_username(d1kt_username)
if existing is None:
user = video_repository.create_sso_user(
username=d1kt_username,
nickname=fullname,
email=email,
sso_provider="d1kt",
)
if user is None:
raise HTTPException(status_code=409, detail="Failed to create SSO user.")
is_new_user = True
else:
user = existing
is_new_user = False
phone_bound = bool(user.get("phone_bound"))
token = video_repository.create_session(user["id"])
return SsoExchangeResponse(
token=token,
user=UserPublic(**user),
phone_bound=phone_bound,
is_new_user=is_new_user,
)
@application.post("/api/v1/auth/sso/bind-phone", response_model=UserPublic)
def sso_bind_phone(
payload: SsoBindPhoneRequest,
user: AuthenticatedUser = Depends(current_user),
) -> UserPublic:
updated_user = video_repository.bind_phone(user.id, payload.phone)
if updated_user is None:
raise HTTPException(status_code=404, detail="User was not found.")
return UserPublic(**updated_user)
@application.get("/api/v1/courses", response_model=List[CourseSummary])
def list_courses(user: AuthenticatedUser = Depends(current_user)) -> List[CourseSummary]:
return [
@@ -224,6 +332,120 @@ def create_app(
raise HTTPException(status_code=404, detail="Course is not available.")
return Response(status_code=204)
@application.post(
"/api/v1/sentences/explain",
response_model=SentenceExplanationResponse,
)
def explain_sentence(
payload: SentenceExplanationRequest,
user: AuthenticatedUser = Depends(current_user),
) -> SentenceExplanationResponse:
del user
try:
explanation = application.state.ai_tutor.explain(
payload.sentence_text,
course_title=payload.course_title,
sentence_index=payload.sentence_index,
language=payload.language or "",
)
except AITutorError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return SentenceExplanationResponse(explanation=explanation)
def stored_subtitle_translations(
document: SentenceBoundaryDocument,
) -> SubtitleTranslationResponse:
return application.state.subtitle_translations.snapshot(document)
@application.get(
"/api/v1/videos/{video_hash}/subtitle-translation",
response_model=SubtitleTranslationResponse,
)
def get_subtitle_translation(
video_hash: str = SHA256_PATH,
user: AuthenticatedUser = Depends(current_user),
) -> SubtitleTranslationResponse:
del user
snapshot = application.state.subtitle_translations.current(video_hash)
if snapshot is None:
raise HTTPException(status_code=404, detail="Course is not available.")
return snapshot
@application.post(
"/api/v1/videos/{video_hash}/subtitle-translation",
response_model=SubtitleTranslationResponse,
)
async def translate_subtitles(
request: Request,
video_hash: str = SHA256_PATH,
user: AuthenticatedUser = Depends(current_user),
) -> SubtitleTranslationResponse:
del user
raw = await request.body()
local_document = None
if raw.strip():
try:
payload = LocalSubtitleTranslationRequest.model_validate_json(raw)
local_document = SentenceBoundaryDocument(
video_hash=video_hash,
duration_ms=payload.duration_ms,
algorithm_version=payload.algorithm_version,
sentences=payload.sentences,
)
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
try:
if local_document is not None:
return application.state.subtitle_translations.ensure_from_local(local_document)
return application.state.subtitle_translations.ensure(video_hash)
except KeyError:
raise HTTPException(status_code=404, detail="Course is not available.") from None
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except AITutorError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@application.post(
"/api/v1/audio/sentence-boundaries",
response_model=SentenceBoundaryDocument,
)
async def generate_sentence_boundaries(
file: UploadFile = File(...),
language: Optional[str] = Form(default=None, max_length=32),
user: AuthenticatedUser = Depends(current_user),
) -> SentenceBoundaryDocument:
del user
max_bytes = min(MAX_AUDIO_BOUNDARY_BYTES, service_settings.max_upload_bytes)
with tempfile.TemporaryDirectory(prefix="audio-boundaries-") as temp_dir:
audio_path = Path(temp_dir) / "audio.wav"
try:
audio_hash, _size = await run_in_threadpool(
_persist_upload,
file.file,
audio_path,
max_bytes,
)
document = await run_in_threadpool(
generate_audio_boundary_document,
video_hash=audio_hash,
audio_path=audio_path,
transcriber=moss,
language=language,
)
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Ad-hoc audio sentence boundary generation failed.")
raise HTTPException(
status_code=502,
detail="Sentence boundary generation failed. Please try again later.",
) from exc
finally:
await file.close()
return document
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
def unenroll_course(
video_hash: str = SHA256_PATH,
@@ -241,6 +463,30 @@ def create_app(
def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]:
return [UserDubShareSummary(**row) for row in video_repository.list_user_dub_shares(user.id)]
@application.delete("/api/v1/me/results/{attempt_id}", status_code=204)
def delete_my_result(
attempt_id: str,
user: AuthenticatedUser = Depends(current_user),
) -> Response:
deleted_result = video_repository.delete_user_result(user.id, attempt_id)
if deleted_result is None:
raise HTTPException(status_code=404, detail="Test result was not found.")
if deleted_result["audio_filename"]:
(service_settings.attempts_dir / Path(deleted_result["audio_filename"]).name).unlink(missing_ok=True)
return Response(status_code=204)
@application.delete("/api/v1/me/dub-shares/{share_id}", status_code=204)
def delete_my_dub_share(
share_id: str,
user: AuthenticatedUser = Depends(current_user),
) -> Response:
audio_filenames = video_repository.delete_user_dub_share(user.id, share_id)
if audio_filenames is None:
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
for filename in audio_filenames:
(service_settings.dub_shares_dir / Path(filename).name).unlink(missing_ok=True)
return Response(status_code=204)
@application.get("/admin", include_in_schema=False)
def admin_page() -> FileResponse:
page = static_dir / "admin.html"
@@ -249,6 +495,39 @@ def create_app(
# 后台页面本身不缓存,静态 JS 通过版本号刷新,避免发布后浏览器继续跑旧逻辑。
return FileResponse(page, headers={"Cache-Control": "no-cache"})
@application.get(
"/api/v1/admin/users",
response_model=UserAdminListResponse,
dependencies=[Depends(require_admin)],
)
def list_registered_users() -> UserAdminListResponse:
return UserAdminListResponse(
users=video_repository.list_users()
)
@application.delete("/api/v1/admin/users/{user_id}", status_code=204)
def delete_registered_user(
user_id: str,
_: None = Depends(require_admin),
) -> Response:
deleted = video_repository.delete_user(user_id)
if not deleted:
raise HTTPException(status_code=404, detail="User was not found.")
return Response(status_code=204)
@application.put(
"/api/v1/admin/users/password",
status_code=204,
dependencies=[Depends(require_admin)],
)
def reset_user_password(
payload: PasswordResetRequest,
) -> Response:
updated = video_repository.set_user_password(payload.username, payload.password)
if not updated:
raise HTTPException(status_code=404, detail="User was not found.")
return Response(status_code=204)
@application.get("/api/v1/videos", response_model=VideoListResponse)
def list_videos() -> VideoListResponse:
return VideoListResponse(
@@ -275,9 +554,13 @@ def create_app(
media_path = service_settings.videos_dir / row["stored_filename"]
if not media_path.is_file():
raise HTTPException(status_code=404, detail="Stored video file is missing.")
media_type = row.get("content_type") or "video/mp4"
if not str(media_type).lower().startswith("video/"):
guessed = mimetypes.guess_type(str(row.get("stored_filename") or ""))[0]
media_type = guessed if guessed and guessed.lower().startswith("video/") else "video/mp4"
return FileResponse(
media_path,
media_type=row["content_type"],
media_type=media_type,
headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"},
)
@@ -414,6 +697,127 @@ def create_app(
background_tasks.add_task(_process_safely, processor, video_hash)
return {"video_hash": video_hash.lower(), "status": "processing_queued"}
@application.post(
"/api/v1/admin/videos/{video_hash}/repair-gaps",
response_model=SentenceGapRepairResponse,
dependencies=[Depends(require_admin)],
)
async def repair_sentence_gaps(
payload: Optional[SentenceGapRepairRequest] = None,
video_hash: str = SHA256_PATH,
) -> SentenceGapRepairResponse:
request = payload or SentenceGapRepairRequest()
try:
result = await run_in_threadpool(
processor.repair_gaps,
video_hash,
request.min_gap_ms,
)
except KeyError:
raise HTTPException(status_code=404, detail="Video was not found.") from None
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if result["inserted_count"]:
application.state.subtitle_translations.start_missing(video_hash)
return SentenceGapRepairResponse(video_hash=video_hash.lower(), **result)
@application.get(
"/api/v1/admin/videos/{video_hash}/subtitle-translation",
response_model=AdminSubtitleTranslationResponse,
dependencies=[Depends(require_admin)],
)
def get_admin_subtitle_translation(
video_hash: str = SHA256_PATH,
) -> AdminSubtitleTranslationResponse:
snapshot = application.state.subtitle_translations.admin_snapshot(video_hash)
if snapshot is None:
raise HTTPException(status_code=404, detail="Course is not available.")
return snapshot
@application.post(
"/api/v1/admin/videos/{video_hash}/subtitle-translation",
response_model=AdminSubtitleTranslationResponse,
dependencies=[Depends(require_admin)],
)
def retranslate_subtitles(
payload: Optional[SubtitleRetranslateRequest] = None,
video_hash: str = SHA256_PATH,
) -> AdminSubtitleTranslationResponse:
request = payload or SubtitleRetranslateRequest()
try:
return application.state.subtitle_translations.retranslate(
video_hash,
terms=[(item.source, item.zh) for item in request.terms],
)
except KeyError:
raise HTTPException(status_code=404, detail="Course is not available.") from None
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except AITutorError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@application.post(
"/api/v1/admin/videos/{video_hash}/subtitle-translation/repair",
response_model=AdminSubtitleTranslationResponse,
dependencies=[Depends(require_admin)],
)
def repair_subtitle_translation(
video_hash: str = SHA256_PATH,
) -> AdminSubtitleTranslationResponse:
try:
return application.state.subtitle_translations.repair_missing(video_hash)
except KeyError:
raise HTTPException(status_code=404, detail="Course is not available.") from None
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except AITutorError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@application.put(
"/api/v1/admin/videos/{video_hash}/title",
response_model=VideoSummary,
dependencies=[Depends(require_admin)],
)
def update_course_title(
payload: CourseTitleUpdate,
video_hash: str = SHA256_PATH,
) -> VideoSummary:
video = video_repository.update_title(video_hash, payload.title)
if video is None:
raise HTTPException(status_code=404, detail="Video was not found.")
return _video_summary(video, service_settings)
@application.put(
"/api/v1/admin/videos/{video_hash}/sentences/batch",
response_model=List[SentenceBoundary],
dependencies=[Depends(require_admin)],
)
def batch_admin_sentences(
payload: AdminSentenceBatchUpdate,
video_hash: str = SHA256_PATH,
) -> List[SentenceBoundary]:
try:
result = _replace_admin_sentences(
repository=video_repository,
transcriber=moss,
settings=service_settings,
video_hash=video_hash,
payload=payload,
)
except KeyError:
raise HTTPException(status_code=404, detail="Course is not available.") from None
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if payload.process_timings:
application.state.subtitle_translations.refresh(video_hash)
else:
application.state.subtitle_translations.invalidate(video_hash)
return result
@application.put(
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}",
response_model=SentenceBoundary,
@@ -432,6 +836,7 @@ def create_app(
)
if sentence is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
application.state.subtitle_translations.invalidate(video_hash)
return sentence
@application.put(
@@ -467,8 +872,31 @@ def create_app(
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
application.state.subtitle_translations.refresh(video_hash)
return result
@application.put(
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/merge",
response_model=SentenceBoundary,
dependencies=[Depends(require_admin)],
)
def merge_sentence(
video_hash: str = SHA256_PATH,
sentence_index: int = ApiPath(ge=0),
) -> SentenceBoundary:
try:
sentence = video_repository.merge_with_next_sentence(
video_hash,
sentence_index,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if sentence is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
application.state.subtitle_translations.invalidate(video_hash)
return sentence
@application.put(
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/boundary",
response_model=List[SentenceBoundary],
@@ -604,7 +1032,6 @@ def create_app(
audio_name = f"{uuid.uuid4().hex}{suffix}"
audio_path = service_settings.dub_shares_dir / audio_name
await run_in_threadpool(_persist_upload, upload.file, audio_path, 50 * 1024 * 1024)
await run_in_threadpool(_align_share_audio, audio_path, boundary.end_ms / 1000, moss)
saved_files.append(audio_path)
score = scores_by_index.get(sentence_index, {})
score_details = {
@@ -726,76 +1153,6 @@ def _persist_upload(source: BinaryIO, destination: Path, max_bytes: int) -> tupl
return digest.hexdigest(), size
def _align_share_audio(audio_path: Path, target_seconds: float, transcriber: Transcriber) -> None:
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
return
aligned_path = audio_path.with_name(f"{audio_path.stem}-aligned{audio_path.suffix}")
try:
transcript = transcriber.transcribe(audio_path)
speech_segments = [segment for segment in transcript.segments if segment.text.strip()]
if not speech_segments:
return
start_seconds = max(0.0, min(segment.start_seconds for segment in speech_segments) - 0.05)
end_seconds = min(
_media_duration_seconds(audio_path),
max(segment.end_seconds for segment in speech_segments) + 0.58,
)
if end_seconds <= start_seconds + 0.1:
return
filters: List[str] = [
"silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.03"
]
if target_seconds > 0.2:
speech_seconds = max(0.1, end_seconds - start_seconds - 0.5)
speed_factor = speech_seconds / target_seconds
if 0.5 <= speed_factor <= 2.0:
filters.append(f"atempo={speed_factor:.6f}")
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ss",
f"{start_seconds:.3f}",
"-t",
f"{end_seconds - start_seconds:.3f}",
"-i",
str(audio_path),
"-af",
",".join(filters),
"-c:a",
"aac",
str(aligned_path),
]
subprocess.run(command, check=True)
if aligned_path.stat().st_size > 0:
aligned_path.replace(audio_path)
except Exception:
logger.warning("Could not align share audio %s; keeping original.", audio_path.name, exc_info=True)
finally:
aligned_path.unlink(missing_ok=True)
def _media_duration_seconds(audio_path: Path) -> float:
ffprobe = shutil.which("ffprobe")
if ffprobe is None:
return 0.0
try:
completed = subprocess.run(
[ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(audio_path)],
check=True,
capture_output=True,
text=True,
)
return float(completed.stdout.strip())
except (OSError, ValueError, subprocess.SubprocessError):
return 0.0
async def _persist_request_stream(
request: Request,
destination: Path,
@@ -874,6 +1231,161 @@ def _process_safely(processor: VideoProcessor, video_hash: str) -> None:
logger.exception("Video processing failed for %s", video_hash)
def _replace_admin_sentences(
*,
repository: VideoRepository,
transcriber: Transcriber,
settings: Settings,
video_hash: str,
payload: AdminSentenceBatchUpdate,
) -> List[SentenceBoundary]:
"""Apply queued admin edits once, instead of after every split/merge."""
video = repository.get_video(video_hash)
old_document = repository.get_document(video_hash)
if video is None or old_document is None:
raise KeyError(video_hash)
old_sentences = {
(sentence.start_ms, sentence.end_ms): sentence
for sentence in old_document.sentences
}
rows: List[SentenceBoundary] = []
index = 0
position = 0
while position < len(payload.sentences):
item = payload.sentences[position]
if item.source_start_ms is None or item.source_end_ms is None:
if "\n" in item.text or "\r" in item.text:
raise ValueError("Unsplit batch sentences cannot contain line breaks.")
rows.append(
SentenceBoundary(
index=index,
start_ms=item.start_ms,
end_ms=item.end_ms,
text=item.text.strip(),
language=item.language,
zh_text=item.zh_text,
reference_speech_duration_ms=item.reference_speech_duration_ms,
)
)
index += 1
position += 1
continue
source_key = (item.source_start_ms, item.source_end_ms)
group_end = position + 1
while group_end < len(payload.sentences):
following = payload.sentences[group_end]
if following.source_start_ms != source_key[0] or following.source_end_ms != source_key[1]:
break
group_end += 1
group = payload.sentences[position:group_end]
source = old_sentences.get(source_key)
if source is None:
if payload.process_timings:
# A split may already have been persisted with provisional
# boundaries. Reconstruct its audio range from the saved rows so
# timing refinement can be retried after a dialog is reopened.
missing = [
item
for item in group
if (item.start_ms, item.end_ms) not in old_sentences
]
if missing:
raise ValueError("The original sentence for a queued split no longer exists.")
source = SentenceBoundary(
index=0,
start_ms=source_key[0],
end_ms=source_key[1],
text="\n".join(item.text.strip() for item in group),
language=next(
(item.language for item in group if item.language is not None),
None,
),
reference_speech_duration_ms=sum(
item.reference_speech_duration_ms for item in group
),
)
else:
source = None
if len(group) == 1:
if "\n" in group[0].text or "\r" in group[0].text:
raise ValueError("Unsplit batch sentences cannot contain line breaks.")
rows.append(
SentenceBoundary(
index=index,
start_ms=group[0].start_ms,
end_ms=group[0].end_ms,
text=group[0].text.strip(),
language=group[0].language,
zh_text=group[0].zh_text,
reference_speech_duration_ms=group[0].reference_speech_duration_ms,
)
)
elif not payload.process_timings:
for item in group:
rows.append(
SentenceBoundary(
index=index,
start_ms=item.start_ms,
end_ms=item.end_ms,
text=item.text.strip(),
language=item.language,
zh_text=item.zh_text,
reference_speech_duration_ms=item.reference_speech_duration_ms,
)
)
index += 1
else:
split_text = "\n".join(item.text.strip() for item in group if item.text.strip())
replacements = split_sentence_text(
transcriber=transcriber,
media_path=settings.videos_dir / video["stored_filename"],
sentence=source,
text=split_text,
language=next(
(item.language for item in group if item.language is not None),
source.language,
),
)
if len(replacements) != len(group):
raise ValueError("Split result count does not match the queued sentences.")
for replacement, item in zip(replacements, group):
rows.append(
SentenceBoundary(
index=index,
start_ms=replacement.start_ms,
end_ms=replacement.end_ms,
text=item.text.strip(),
language=(
item.language
if item.language is not None
else source.language
),
zh_text=None,
reference_speech_duration_ms=replacement.reference_speech_duration_ms,
)
)
index += 1
index = len(rows)
position = group_end
duration_ms = video["duration_ms"]
if duration_ms is None:
raise ValueError("Course duration is not available.")
previous_end = 0
for row in rows:
if row.start_ms < previous_end:
raise ValueError("Batch sentences must be sorted and non-overlapping.")
if row.end_ms > duration_ms:
raise ValueError("Batch sentences cannot exceed the course duration.")
previous_end = row.end_ms
document = old_document.model_copy(update={"sentences": rows})
return repository.replace_sentences(document).sentences
def _video_summary(row: Dict[str, Any], settings: Settings) -> VideoSummary:
if row.get("remote_url"):
stream_url = row["remote_url"]

View File

@@ -12,6 +12,7 @@ class SentenceBoundary(BaseModel):
end_ms: int = Field(gt=0)
text: Optional[str] = None
language: Optional[str] = None
zh_text: Optional[str] = None
reference_speech_duration_ms: Optional[int] = Field(default=None, gt=0)
@model_validator(mode="after")
@@ -51,7 +52,7 @@ class SentenceBoundaryDocument(BaseModel):
return self
VideoStatus = Literal["uploaded", "processing", "ready", "failed"]
VideoStatus = Literal["uploaded", "processing", "ready", "failed", "local_subtitles"]
class VideoSummary(BaseModel):
@@ -92,12 +93,147 @@ class SentenceTextUpdate(BaseModel):
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):
expected: str
actual: str
@@ -155,6 +291,34 @@ class AuthResponse(BaseModel):
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

View File

@@ -4,17 +4,20 @@ import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import List, Optional
from typing import Any, Dict, List, Optional, Sequence, Tuple
from .audio_metrics import (
AudioAnalysisError,
analyze_samples,
decode_audio_mono,
FRAME_SAMPLES,
refine_sentence_end_ms,
refine_sentence_start_ms,
speech_frame_mask,
)
from .config import Settings
from .generate_boundaries import ALGORITHM_VERSION, make_entry
from .generate_boundaries import ALGORITHM_VERSION, make_entry, media_duration_ms
from sentence_analysis import detect_sentence_boundaries
from .models import SentenceBoundary, SentenceBoundaryDocument
from .oss import VolcanoOSSUploader
from .repository import VideoRepository
@@ -26,7 +29,72 @@ from .transcription import (
logger = logging.getLogger(__name__)
MOSS_ALGORITHM_VERSION = "moss-punctuation-v3"
MOSS_ALGORITHM_VERSION = "moss-punctuation-v4"
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
GAP_REPAIR_MIN_MS = 8_000
GAP_REPAIR_INSET_MS = 150
GAP_REPAIR_MIN_SPEECH_MS = 400
GAP_REPAIR_MIN_SENTENCE_MS = 100
GAP_REPAIR_TARGET_CHUNK_MS = 15_000
GAP_REPAIR_MAX_CHUNK_MS = 20_000
GAP_REPAIR_MIN_CHUNK_MS = 5_000
GAP_REPAIR_MIN_SPLIT_SILENCE_MS = 120
GAP_REPAIR_FALLBACK_LANGUAGE = "en"
_CUES_ID = b"\x1e\x53\xb6\x80"
_CLUSTER_ID = b"\x1f\x43\xb6\x75"
_HEADER_SCAN_SIZE = 64 * 1024
_WEBM_SUFFIXES = {".webm", ".mkv"}
def _webm_cues_at_front(filepath: Path) -> bool:
"""Return True if WebM/MKV CUES appear before the first Cluster."""
try:
with open(filepath, "rb") as f:
header = f.read(_HEADER_SCAN_SIZE)
except OSError:
return True # can't read → assume OK, don't touch
cues_pos = header.find(_CUES_ID)
cluster_pos = header.find(_CLUSTER_ID)
if cues_pos != -1 and (cluster_pos == -1 or cues_pos < cluster_pos):
return True
return False
def _ensure_webm_cues_front(filepath: Path) -> None:
"""Re-mux a WebM/MKV file (stream copy) if CUES are not at the front.
YouTube-dl / yt-dlp writes CUES at the end by default, which slows HTTP
Range seeking. This runs once during upload processing and takes seconds.
"""
if filepath.suffix.lower() not in _WEBM_SUFFIXES:
return
if _webm_cues_at_front(filepath):
return
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
logger.warning("ffmpeg not found; skipping WebM CUES optimization.")
return
tmp = filepath.with_suffix(".cues_tmp" + filepath.suffix)
try:
result = subprocess.run(
[ffmpeg, "-y", "-loglevel", "error",
"-i", str(filepath), "-c", "copy",
"-cues_to_front", "1", str(tmp)],
capture_output=True, timeout=300,
)
if result.returncode == 0 and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(filepath)
logger.info("WebM CUES moved to front: %s", filepath.name)
else:
logger.warning(
"WebM CUES re-mux failed for %s: rc=%d stderr=%s",
filepath.name, result.returncode, result.stderr.decode()[-200:],
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.warning("WebM CUES re-mux error for %s: %s", filepath.name, exc)
finally:
tmp.unlink(missing_ok=True)
class VideoProcessor:
@@ -53,6 +121,7 @@ class VideoProcessor:
self.repository.mark_processing(video_hash)
work_path: Optional[Path] = None
try:
_ensure_webm_cues_front(media_path)
if self.transcriber.available:
work_path = self.settings.work_dir / f"{video_hash}-{uuid.uuid4().hex}.wav"
extract_audio(media_path, work_path)
@@ -67,7 +136,11 @@ class VideoProcessor:
)
if not document.sentences:
raise RuntimeError("MOSS returned no timestamped speech segments.")
self._finish_processing(document, transcript.text)
self._finish_processing(
document,
transcript.text,
moss_segments=_serialize_moss_segments(transcript),
)
else:
entry, _ = make_entry(media_path, video_hash=video_hash)
document = SentenceBoundaryDocument(
@@ -88,13 +161,18 @@ class VideoProcessor:
self,
document: SentenceBoundaryDocument,
transcription: Optional[str],
moss_segments: Optional[List[Dict[str, Any]]] = None,
) -> None:
if self.oss_uploader is None or not self.oss_uploader.enabled:
self.repository.save_processing_result(document, transcription, ready=True)
self.repository.save_processing_result(
document, transcription, ready=True, moss_segments=moss_segments
)
return
# Keep the course invisible while the mandatory OSS upload is running.
self.repository.save_processing_result(document, transcription, ready=False)
self.repository.save_processing_result(
document, transcription, ready=False, moss_segments=moss_segments
)
video = self.repository.get_video(document.video_hash)
if video is None:
raise ValueError(f"Unknown video: {document.video_hash}")
@@ -106,6 +184,338 @@ class VideoProcessor:
)
self.repository.mark_oss_uploaded(document.video_hash, remote_url)
def repair_gaps(
self,
video_hash: str,
min_gap_ms: int = GAP_REPAIR_MIN_MS,
) -> Dict[str, int]:
"""Re-transcribe long unsentenced gaps without replacing existing cues."""
if min_gap_ms < 1:
raise ValueError("min_gap_ms must be positive")
if not self.transcriber.available:
raise RuntimeError("MOSS transcription is not configured on this server.")
video = self.repository.get_video(video_hash)
if video is None:
raise KeyError(video_hash)
document = self.repository.get_document(video_hash)
if document is None or not document.sentences:
raise ValueError("Sentence boundaries are not available for this video.")
media_path = self.settings.videos_dir / video["stored_filename"]
if not media_path.is_file():
raise FileNotFoundError(f"Stored video is missing: {media_path.name}")
gaps = find_sentence_gaps(document, min_gap_ms)
inserted: List[SentenceBoundary] = []
transcribed_gaps = 0
logger.info(
"Gap repair started: video=%s min_gap_ms=%d language=%s gaps=%d "
"sentences=%d duration_ms=%d transcriber=%s",
video_hash,
min_gap_ms,
video.get("language") or "auto",
len(gaps),
len(document.sentences),
document.duration_ms,
type(self.transcriber).__name__,
)
if gaps:
language = video.get("language")
with tempfile.TemporaryDirectory(prefix="gap-repair-") as temp_dir:
for gap_index, (raw_start, raw_end) in enumerate(gaps):
logger.info(
"Gap %d/%d: raw=%d-%dms duration=%dms",
gap_index + 1,
len(gaps),
raw_start,
raw_end,
raw_end - raw_start,
)
clipped = _inset_gap(raw_start, raw_end, document.duration_ms)
if clipped is None:
logger.warning(
"Gap %d/%d skipped after inset: raw=%d-%dms",
gap_index + 1,
len(gaps),
raw_start,
raw_end,
)
continue
start_ms, end_ms = clipped
chunk_path = Path(temp_dir) / f"gap-{gap_index}-full.wav"
logger.info(
"Gap %d/%d extracted range=%d-%dms duration=%dms file=%s",
gap_index + 1,
len(gaps),
start_ms,
end_ms,
end_ms - start_ms,
chunk_path,
)
try:
extract_audio_segment(
media_path,
chunk_path,
start_ms=start_ms,
duration_ms=end_ms - start_ms,
)
samples, sample_rate = decode_audio_mono(chunk_path)
metrics = analyze_samples(samples, sample_rate)
except (AudioAnalysisError, RuntimeError) as exc:
logger.warning(
"Skipping sentence gap %s-%sms: %s",
start_ms,
end_ms,
exc,
exc_info=True,
)
continue
logger.info(
"Gap %d/%d audio metrics: duration_ms=%d speech_duration_ms=%d "
"internal_silence_ms=%d pause_ratio=%.3f threshold_ms=%d",
gap_index + 1,
len(gaps),
metrics.recording_duration_ms,
metrics.speech_duration_ms,
metrics.internal_silence_ms,
metrics.internal_pause_ratio,
GAP_REPAIR_MIN_SPEECH_MS,
)
if metrics.speech_duration_ms < GAP_REPAIR_MIN_SPEECH_MS:
logger.warning(
"Gap %d/%d skipped as silence: speech_duration_ms=%d < %d",
gap_index + 1,
len(gaps),
metrics.speech_duration_ms,
GAP_REPAIR_MIN_SPEECH_MS,
)
continue
relative_chunks = _plan_gap_chunks(samples, sample_rate, end_ms - start_ms)
logger.info(
"Gap %d/%d split into %d transcription chunks: %s",
gap_index + 1,
len(gaps),
len(relative_chunks),
relative_chunks,
)
gap_inserted_count = 0
for chunk_index, (chunk_offset_start, chunk_offset_end) in enumerate(
relative_chunks
):
chunk_start = start_ms + chunk_offset_start
chunk_end = start_ms + chunk_offset_end
chunk_path = (
Path(temp_dir) / f"gap-{gap_index}-chunk-{chunk_index}.wav"
)
try:
extract_audio_segment(
media_path,
chunk_path,
start_ms=chunk_start,
duration_ms=chunk_end - chunk_start,
)
chunk_samples, chunk_sample_rate = decode_audio_mono(chunk_path)
chunk_metrics = analyze_samples(
chunk_samples, chunk_sample_rate
)
except (AudioAnalysisError, RuntimeError) as exc:
logger.warning(
"Could not prepare gap chunk %d/%d %s-%sms: %s",
chunk_index + 1,
len(relative_chunks),
chunk_start,
chunk_end,
exc,
exc_info=True,
)
continue
if chunk_metrics.speech_duration_ms < GAP_REPAIR_MIN_SPEECH_MS:
logger.info(
"Skipping quiet gap chunk %d/%d: range=%d-%dms "
"speech_duration_ms=%d",
chunk_index + 1,
len(relative_chunks),
chunk_start,
chunk_end,
chunk_metrics.speech_duration_ms,
)
continue
languages_to_try = [language]
if not language or language.strip().lower() == "auto":
fallback = GAP_REPAIR_FALLBACK_LANGUAGE
if fallback not in languages_to_try:
languages_to_try.append(fallback)
transcript = None
for attempt_index, attempt_language in enumerate(languages_to_try):
try:
logger.info(
"Transcribing gap chunk %d/%d: gap=%d/%d "
"range=%d-%dms language=%s",
chunk_index + 1,
len(relative_chunks),
gap_index + 1,
len(gaps),
chunk_start,
chunk_end,
attempt_language or "auto",
)
transcript = self.transcriber.transcribe(
chunk_path, attempt_language
)
except Exception as exc:
logger.warning(
"Gap chunk transcription failed for %s %s-%sms: %s",
video_hash,
chunk_start,
chunk_end,
exc,
exc_info=True,
)
continue
logger.info(
"Gap chunk transcript returned: gap=%d/%d chunk=%d/%d "
"segments=%d text=%r",
gap_index + 1,
len(gaps),
chunk_index + 1,
len(relative_chunks),
len(transcript.segments),
transcript.text,
)
if transcript.segments:
break
if attempt_index + 1 < len(languages_to_try):
logger.warning(
"Gap chunk returned no segments; retrying with %s: "
"range=%d-%dms",
languages_to_try[attempt_index + 1],
chunk_start,
chunk_end,
)
if transcript is None or not transcript.segments:
logger.warning(
"Gap chunk produced no transcript segments: gap=%d/%d "
"chunk=%d/%d range=%d-%dms attempts=%s",
gap_index + 1,
len(gaps),
chunk_index + 1,
len(relative_chunks),
chunk_start,
chunk_end,
[item or "auto" for item in languages_to_try],
)
continue
gap_document = document_from_transcript(
video_hash=video_hash,
duration_ms=chunk_end - chunk_start,
transcript=transcript,
language=language,
audio_path=chunk_path,
end_padding_ms=self.settings.moss_end_padding_ms,
)
shifted = _shift_gap_sentences(
gap_document.sentences,
offset_ms=chunk_start,
limit_end_ms=chunk_end,
)
if not shifted:
logger.warning(
"Gap chunk produced no insertable sentences: gap=%d/%d "
"chunk=%d/%d transcript_segments=%d "
"boundary_sentences=%d range=%d-%dms",
gap_index + 1,
len(gaps),
chunk_index + 1,
len(relative_chunks),
len(transcript.segments),
len(gap_document.sentences),
chunk_start,
chunk_end,
)
for boundary in gap_document.sentences:
logger.warning(
"Candidate boundary before shift: start=%d end=%d text=%r",
boundary.start_ms,
boundary.end_ms,
boundary.text,
)
continue
inserted.extend(shifted)
gap_inserted_count += len(shifted)
logger.info(
"Gap chunk produced %d insertable sentences: gap=%d/%d "
"chunk=%d/%d range=%d-%dms",
len(shifted),
gap_index + 1,
len(gaps),
chunk_index + 1,
len(relative_chunks),
chunk_start,
chunk_end,
)
if gap_inserted_count:
transcribed_gaps += 1
logger.info(
"Gap %d/%d produced %d insertable sentences total: range=%d-%dms",
gap_index + 1,
len(gaps),
gap_inserted_count,
start_ms,
end_ms,
)
else:
logger.warning(
"Gap %d/%d produced no insertable sentences after chunking: "
"range=%d-%dms chunks=%d",
gap_index + 1,
len(gaps),
start_ms,
end_ms,
len(relative_chunks),
)
if not inserted:
logger.warning(
"Gap repair finished without inserts: video=%s gaps=%d "
"transcribed_gaps=%d inserted=0",
video_hash,
len(gaps),
transcribed_gaps,
)
return {
"min_gap_ms": min_gap_ms,
"gap_count": len(gaps),
"transcribed_gaps": transcribed_gaps,
"inserted_count": 0,
"sentence_count": len(document.sentences),
}
merged = _merge_sentence_lists(document.sentences, inserted)
updated = document.model_copy(update={"sentences": merged})
saved = self.repository.replace_sentences(updated)
logger.info(
"Gap repair finished: video=%s gaps=%d transcribed_gaps=%d inserted=%d "
"sentences=%d->%d",
video_hash,
len(gaps),
transcribed_gaps,
len(saved.sentences) - len(document.sentences),
len(document.sentences),
len(saved.sentences),
)
return {
"min_gap_ms": min_gap_ms,
"gap_count": len(gaps),
"transcribed_gaps": transcribed_gaps,
"inserted_count": len(saved.sentences) - len(document.sentences),
"sentence_count": len(saved.sentences),
}
def extract_audio(media_path: Path, output_path: Path) -> None:
ffmpeg = shutil.which("ffmpeg")
@@ -174,6 +584,169 @@ def extract_audio_segment(
raise RuntimeError(f"Could not extract sentence audio: {message[-2000:]}")
def find_sentence_gaps(
document: SentenceBoundaryDocument,
min_gap_ms: int = GAP_REPAIR_MIN_MS,
) -> List[Tuple[int, int]]:
"""Return [start_ms, end_ms) ranges that are missing sentence coverage."""
if min_gap_ms < 1:
raise ValueError("min_gap_ms must be positive")
gaps: List[Tuple[int, int]] = []
cursor = 0
for sentence in document.sentences:
if sentence.start_ms - cursor >= min_gap_ms:
gaps.append((cursor, sentence.start_ms))
cursor = max(cursor, sentence.end_ms)
if document.duration_ms - cursor >= min_gap_ms:
gaps.append((cursor, document.duration_ms))
return gaps
def _inset_gap(
start_ms: int,
end_ms: int,
duration_ms: int,
inset_ms: int = GAP_REPAIR_INSET_MS,
) -> Optional[Tuple[int, int]]:
start = start_ms + (inset_ms if start_ms > 0 else 0)
end = end_ms - (inset_ms if end_ms < duration_ms else 0)
if end - start < GAP_REPAIR_MIN_SPEECH_MS:
return None
return start, end
def _plan_gap_chunks(
samples,
sample_rate: int,
duration_ms: int,
) -> List[Tuple[int, int]]:
"""Split a long gap near silence so no ASR request exceeds Whisper's window."""
if duration_ms <= GAP_REPAIR_MAX_CHUNK_MS:
return [(0, duration_ms)]
chunks: List[Tuple[int, int]] = []
cursor = 0
while cursor < duration_ms:
remaining = duration_ms - cursor
if remaining <= GAP_REPAIR_MAX_CHUNK_MS:
chunks.append((cursor, duration_ms))
break
preferred = min(cursor + GAP_REPAIR_TARGET_CHUNK_MS, duration_ms)
lower = min(cursor + GAP_REPAIR_MIN_CHUNK_MS, preferred)
upper = min(cursor + GAP_REPAIR_MAX_CHUNK_MS, duration_ms)
cut = _find_silence_cut(
samples,
sample_rate,
lower,
upper,
preferred,
min_silence_ms=GAP_REPAIR_MIN_SPLIT_SILENCE_MS,
)
cut = max(cursor + 1, min(duration_ms, cut))
chunks.append((cursor, cut))
cursor = cut
return chunks
def _find_silence_cut(
samples,
sample_rate: int,
lower_ms: int,
upper_ms: int,
preferred_ms: int,
*,
min_silence_ms: int,
) -> int:
"""Choose the silence midpoint nearest ``preferred_ms``."""
frame_ms = 1000.0 * FRAME_SAMPLES / sample_rate
min_silence_frames = max(1, int(round(min_silence_ms / frame_ms)))
start_sample = max(0, int(lower_ms / 1000 * sample_rate))
end_sample = min(samples.size, int(upper_ms / 1000 * sample_rate) + 1)
if end_sample <= start_sample:
return preferred_ms
try:
speech = speech_frame_mask(samples[start_sample:end_sample], sample_rate)
except AudioAnalysisError:
return preferred_ms
run_start: Optional[int] = None
candidates: List[Tuple[int, int]] = []
for index, is_speech in enumerate(speech):
if not is_speech:
if run_start is None:
run_start = index
continue
if run_start is not None:
candidates.append((run_start, index))
run_start = None
if run_start is not None:
candidates.append((run_start, len(speech)))
valid = [
(run_start, run_end)
for run_start, run_end in candidates
if run_end - run_start >= min_silence_frames
]
if not valid:
return preferred_ms
def distance(run: Tuple[int, int]) -> float:
midpoint_frame = (run[0] + run[1]) / 2.0
midpoint_ms = lower_ms + midpoint_frame * frame_ms
return abs(midpoint_ms - preferred_ms)
run_start, run_end = min(valid, key=distance)
midpoint_ms = lower_ms + ((run_start + run_end) / 2.0) * frame_ms
return int(round(midpoint_ms))
def _shift_gap_sentences(
sentences: Sequence[SentenceBoundary],
*,
offset_ms: int,
limit_end_ms: int,
) -> List[SentenceBoundary]:
shifted: List[SentenceBoundary] = []
previous_end = offset_ms
for sentence in sentences:
start_ms = max(previous_end, sentence.start_ms + offset_ms)
end_ms = min(limit_end_ms, sentence.end_ms + offset_ms)
text = (sentence.text or "").strip()
if not text or end_ms - start_ms < GAP_REPAIR_MIN_SENTENCE_MS:
continue
shifted.append(
sentence.model_copy(
update={
"index": len(shifted),
"start_ms": start_ms,
"end_ms": end_ms,
"text": text,
"zh_text": None,
}
)
)
previous_end = end_ms
return shifted
def _merge_sentence_lists(
existing: Sequence[SentenceBoundary],
inserted: Sequence[SentenceBoundary],
) -> List[SentenceBoundary]:
combined = [*existing, *inserted]
combined.sort(key=lambda sentence: (sentence.start_ms, sentence.end_ms))
merged: List[SentenceBoundary] = []
previous_end = 0
for sentence in combined:
if sentence.start_ms < previous_end:
continue
merged.append(sentence.model_copy(update={"index": len(merged)}))
previous_end = sentence.end_ms
return merged
def split_sentence_text(
*,
transcriber: Transcriber,
@@ -417,7 +990,97 @@ def document_from_transcript(
)
def generate_audio_boundary_document(
*,
video_hash: str,
audio_path: Path,
transcriber: Transcriber,
language: Optional[str] = None,
) -> SentenceBoundaryDocument:
"""Generate sentence boundaries for a client-supplied audio file.
The caller owns ``audio_path`` and is responsible for deleting it. Nothing
in this function copies or registers the audio in application storage.
"""
duration_ms = media_duration_ms(audio_path)
transcript = None
if transcriber.available:
try:
transcript = transcriber.transcribe(audio_path, language)
except Exception as exc:
logger.warning("Audio boundary transcription failed; using silence fallback: %s", exc)
if transcript is not None:
document = document_from_transcript(
video_hash=video_hash,
duration_ms=duration_ms,
transcript=transcript,
language=language,
audio_path=audio_path,
)
if document.sentences:
return document
starts = detect_sentence_boundaries(audio_path)
if not starts:
raise ValueError("No sentence boundaries could be detected in the audio.")
starts_ms = sorted({max(0, min(duration_ms - 1, int(round(start * 1000)))) for start in starts})
sentences: List[SentenceBoundary] = []
for index, start_ms in enumerate(starts_ms):
end_ms = starts_ms[index + 1] if index + 1 < len(starts_ms) else duration_ms
if end_ms <= start_ms:
continue
sentences.append(
SentenceBoundary(
index=len(sentences),
start_ms=start_ms,
end_ms=end_ms,
text=None,
language=language,
)
)
if not sentences:
raise ValueError("Detected audio boundaries do not form valid sentence ranges.")
return SentenceBoundaryDocument(
video_hash=video_hash,
duration_ms=duration_ms,
algorithm_version=SILENCE_ALGORITHM_VERSION,
sentences=sentences,
)
def _media_duration_ms(path: Path) -> int:
from .generate_boundaries import media_duration_ms
return media_duration_ms(path)
def _serialize_moss_segments(transcript: Transcript) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for segment in transcript.segments:
text = segment.text.strip()
if not text:
continue
words = None
if segment.words:
words = [
{
"start_seconds": word.start_seconds,
"end_seconds": word.end_seconds,
"text": word.text,
}
for word in segment.words
]
rows.append(
{
"start_seconds": segment.start_seconds,
"end_seconds": segment.end_seconds,
"text": text,
"speaker": segment.speaker,
"words": words,
}
)
return rows

View File

@@ -2,7 +2,7 @@ import json
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Sequence, Tuple
import uuid
from .auth import hash_password, hash_token, new_token, verify_password
@@ -56,6 +56,7 @@ class VideoRepository:
end_ms INTEGER NOT NULL,
text TEXT,
language TEXT,
zh_text TEXT,
reference_speech_duration_ms INTEGER,
PRIMARY KEY (video_hash, sentence_index),
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
@@ -120,6 +121,20 @@ class VideoRepository:
FOREIGN KEY (share_id) REFERENCES dub_shares(share_id) ON DELETE CASCADE,
UNIQUE (share_id, sentence_index)
);
CREATE TABLE IF NOT EXISTS moss_segments (
video_hash TEXT NOT NULL,
segment_index INTEGER NOT NULL,
start_seconds REAL NOT NULL,
end_seconds REAL NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
text TEXT NOT NULL,
speaker TEXT,
words_json TEXT,
PRIMARY KEY (video_hash, segment_index),
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
);
"""
)
@@ -156,6 +171,10 @@ class VideoRepository:
}
if "phone" not in user_columns:
connection.execute("ALTER TABLE users ADD COLUMN phone TEXT")
if "sso_provider" not in user_columns:
connection.execute("ALTER TABLE users ADD COLUMN sso_provider TEXT")
if "phone_bound" not in user_columns:
connection.execute("ALTER TABLE users ADD COLUMN phone_bound INTEGER NOT NULL DEFAULT 0")
connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)")
with self._connect() as connection:
@@ -175,6 +194,22 @@ class VideoRepository:
if "remote_url" not in video_columns:
connection.execute("ALTER TABLE videos ADD COLUMN remote_url TEXT")
with self._connect() as connection:
sentence_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(sentences)")
}
if "zh_text" not in sentence_columns:
connection.execute("ALTER TABLE sentences ADD COLUMN zh_text TEXT")
with self._connect() as connection:
video_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(videos)")
}
if "translation_glossary" not in video_columns:
connection.execute("ALTER TABLE videos ADD COLUMN translation_glossary TEXT")
def upsert_upload(
self,
*,
@@ -226,7 +261,24 @@ class VideoRepository:
)
def mark_processing(self, video_hash: str) -> None:
self._update_status(video_hash, "processing", None)
normalized_hash = normalize_video_hash(video_hash)
with self._connect() as connection:
connection.execute(
"DELETE FROM sentences WHERE video_hash = ?",
(normalized_hash,),
)
connection.execute(
"DELETE FROM moss_segments WHERE video_hash = ?",
(normalized_hash,),
)
connection.execute(
"""
UPDATE videos
SET status = ?, error_message = NULL, transcription = NULL, updated_at = ?
WHERE video_hash = ?
""",
("processing", utc_now(), normalized_hash),
)
def mark_failed(self, video_hash: str, message: str) -> None:
self._update_status(video_hash, "failed", message[:4000])
@@ -243,12 +295,17 @@ class VideoRepository:
document: SentenceBoundaryDocument,
transcription: Optional[str],
ready: bool = True,
moss_segments: Optional[List[Dict[str, Any]]] = None,
) -> None:
with self._connect() as connection:
connection.execute(
"DELETE FROM sentences WHERE video_hash = ?",
(document.video_hash,),
)
connection.execute(
"DELETE FROM moss_segments WHERE video_hash = ?",
(document.video_hash,),
)
connection.executemany(
"""
INSERT INTO sentences (
@@ -284,6 +341,111 @@ class VideoRepository:
document.video_hash,
),
)
if moss_segments:
connection.executemany(
"""
INSERT INTO moss_segments (
video_hash, segment_index, start_seconds, end_seconds,
start_ms, end_ms, text, speaker, words_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
_moss_segment_row(document.video_hash, index, segment)
for index, segment in enumerate(moss_segments)
],
)
def import_local_subtitle_document(
self,
document: SentenceBoundaryDocument,
) -> SentenceBoundaryDocument:
"""Save locally generated English subtitles for translation.
Official course videos are left unchanged. A previous local-only
import is replaced when the English cues changed.
"""
normalized_hash = normalize_video_hash(document.video_hash)
document = document.model_copy(update={"video_hash": normalized_hash})
existing_video = self.get_video(normalized_hash)
existing_document = self.get_document(normalized_hash)
if existing_video is not None and existing_video.get("status") != "local_subtitles":
if existing_document is None:
raise KeyError(normalized_hash)
return existing_document
if existing_document is not None and _english_fingerprint(existing_document) == _english_fingerprint(document):
return existing_document
now = utc_now()
language = next((sentence.language for sentence in document.sentences if sentence.language), None)
with self._connect() as connection:
if existing_video is None:
connection.execute(
"""
INSERT INTO videos (
video_hash, title, filename, stored_filename, content_type,
size_bytes, duration_ms, language, status, error_message,
algorithm_version, transcription, remote_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'local_subtitles', NULL, ?, NULL, NULL, ?, ?)
""",
(
normalized_hash,
"Local subtitles",
"local-subtitles.json",
"",
"application/json",
0,
document.duration_ms,
language,
document.algorithm_version,
now,
now,
),
)
else:
connection.execute(
"""
UPDATE videos
SET duration_ms = ?, language = COALESCE(?, language),
algorithm_version = ?, status = 'local_subtitles',
error_message = NULL, updated_at = ?
WHERE video_hash = ?
""",
(
document.duration_ms,
language,
document.algorithm_version,
now,
normalized_hash,
),
)
connection.execute(
"DELETE FROM sentences WHERE video_hash = ?",
(normalized_hash,),
)
connection.executemany(
"""
INSERT INTO sentences (
video_hash, sentence_index, start_ms, end_ms, text,
language, zh_text, reference_speech_duration_ms
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
""",
[
(
normalized_hash,
sentence.index,
sentence.start_ms,
sentence.end_ms,
sentence.text,
sentence.language,
sentence.reference_speech_duration_ms,
)
for sentence in document.sentences
],
)
imported = self.get_document(normalized_hash)
if imported is None:
raise KeyError(normalized_hash)
return imported
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
with self._connect() as connection:
@@ -296,6 +458,22 @@ class VideoRepository:
(remote_url, utc_now(), normalize_video_hash(video_hash)),
)
def update_title(self, video_hash: str, title: str) -> Optional[Dict[str, Any]]:
normalized_hash = normalize_video_hash(video_hash)
cleaned_title = title.strip()[:200]
if not cleaned_title:
raise ValueError("Title cannot be empty.")
with self._connect() as connection:
cursor = connection.execute(
"UPDATE videos SET title = ?, updated_at = ? WHERE video_hash = ?",
(cleaned_title, utc_now(), normalized_hash),
)
if cursor.rowcount == 0:
return None
return self.get_video(normalized_hash)
def list_videos(self) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
@@ -303,6 +481,7 @@ class VideoRepository:
SELECT v.*, COUNT(s.sentence_index) AS sentence_count
FROM videos v
LEFT JOIN sentences s ON s.video_hash = v.video_hash
WHERE v.status != 'local_subtitles'
GROUP BY v.video_hash
ORDER BY v.created_at DESC
"""
@@ -323,6 +502,32 @@ class VideoRepository:
).fetchone()
return dict(row) if row else None
def get_moss_segments(self, video_hash: str) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT * FROM moss_segments
WHERE video_hash = ?
ORDER BY segment_index
""",
(normalize_video_hash(video_hash),),
).fetchall()
segments = []
for row in rows:
item = dict(row)
raw_words = item.pop("words_json", None)
words = None
if raw_words:
try:
parsed = json.loads(raw_words)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
words = parsed
item["words"] = words
segments.append(item)
return segments
def get_document(self, video_hash: str) -> Optional[SentenceBoundaryDocument]:
video = self.get_video(video_hash)
if video is None or video["duration_ms"] is None:
@@ -336,18 +541,55 @@ class VideoRepository:
video_hash=video["video_hash"],
duration_ms=video["duration_ms"],
algorithm_version=video["algorithm_version"] or "unknown",
sentences=[
SentenceBoundary(
index=row["sentence_index"],
start_ms=row["start_ms"],
end_ms=row["end_ms"],
text=row["text"],
language=row["language"],
reference_speech_duration_ms=row["reference_speech_duration_ms"],
sentences=[_sentence_from_row(row) for row in rows],
)
for row in rows
def replace_sentences(
self,
document: SentenceBoundaryDocument,
) -> SentenceBoundaryDocument:
"""Replace stored sentences while preserving video status and translations."""
normalized_hash = normalize_video_hash(document.video_hash)
with self._connect() as connection:
existing = connection.execute(
"SELECT video_hash FROM videos WHERE video_hash = ?",
(normalized_hash,),
).fetchone()
if existing is None:
raise KeyError(normalized_hash)
connection.execute(
"DELETE FROM sentences WHERE video_hash = ?",
(normalized_hash,),
)
connection.executemany(
"""
INSERT INTO sentences (
video_hash, sentence_index, start_ms, end_ms, text,
language, zh_text, reference_speech_duration_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
normalized_hash,
sentence.index,
sentence.start_ms,
sentence.end_ms,
sentence.text,
sentence.language,
sentence.zh_text,
sentence.reference_speech_duration_ms,
)
for sentence in document.sentences
],
)
connection.execute(
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
(utc_now(), normalized_hash),
)
saved = self.get_document(normalized_hash)
if saved is None:
raise KeyError(normalized_hash)
return saved
def update_sentence_text(
self,
@@ -360,7 +602,8 @@ class VideoRepository:
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE sentences SET text = ?, language = COALESCE(?, language)
UPDATE sentences SET text = ?, language = COALESCE(?, language),
zh_text = NULL
WHERE video_hash = ? AND sentence_index = ?
""",
(text.strip(), language, normalized_hash, sentence_index),
@@ -375,14 +618,7 @@ class VideoRepository:
"SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ?",
(normalized_hash, sentence_index),
).fetchone()
return SentenceBoundary(
index=row["sentence_index"],
start_ms=row["start_ms"],
end_ms=row["end_ms"],
text=row["text"],
language=row["language"],
reference_speech_duration_ms=row["reference_speech_duration_ms"],
)
return _sentence_from_row(row)
def get_sentence(
self,
@@ -400,14 +636,7 @@ class VideoRepository:
).fetchone()
if row is None:
return None
return SentenceBoundary(
index=row["sentence_index"],
start_ms=row["start_ms"],
end_ms=row["end_ms"],
text=row["text"],
language=row["language"],
reference_speech_duration_ms=row["reference_speech_duration_ms"],
)
return _sentence_from_row(row)
def replace_sentence(
self,
@@ -519,6 +748,10 @@ class VideoRepository:
replacement.reference_speech_duration_ms,
),
)
connection.execute(
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
(normalized_hash,),
)
connection.execute(
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
(utc_now(), normalized_hash),
@@ -532,17 +765,109 @@ class VideoRepository:
(normalized_hash, sentence_index, sentence_index + len(rows)),
).fetchall()
return [
SentenceBoundary(
index=row["sentence_index"],
start_ms=row["start_ms"],
end_ms=row["end_ms"],
text=row["text"],
language=row["language"],
reference_speech_duration_ms=row["reference_speech_duration_ms"],
return [_sentence_from_row(row) for row in result_rows]
def merge_with_next_sentence(
self,
video_hash: str,
sentence_index: int,
) -> Optional[SentenceBoundary]:
"""Merge sentence_index with the immediately following sentence."""
normalized_hash = normalize_video_hash(video_hash)
with self._connect() as connection:
current = connection.execute(
"""
SELECT * FROM sentences
WHERE video_hash = ? AND sentence_index = ?
""",
(normalized_hash, sentence_index),
).fetchone()
if current is None:
return None
nxt = connection.execute(
"""
SELECT * FROM sentences
WHERE video_hash = ? AND sentence_index = ?
""",
(normalized_hash, sentence_index + 1),
).fetchone()
if nxt is None:
raise ValueError("No next sentence to merge with.")
merged_text = _join_adjacent_sentence_text(
current["text"] or "",
nxt["text"] or "",
current["language"] or nxt["language"],
)
for row in result_rows
merged_language = current["language"] or nxt["language"]
merged_speech_duration = max(
1,
int(current["reference_speech_duration_ms"] or 0)
+ int(nxt["reference_speech_duration_ms"] or 0),
)
if nxt["end_ms"] <= current["start_ms"]:
raise ValueError("Merged sentence times must increase.")
connection.execute(
"""
UPDATE sentences
SET end_ms = ?, text = ?, language = ?,
reference_speech_duration_ms = ?, zh_text = NULL
WHERE video_hash = ? AND sentence_index = ?
""",
(
nxt["end_ms"],
merged_text,
merged_language,
merged_speech_duration,
normalized_hash,
sentence_index,
),
)
connection.execute(
"""
DELETE FROM sentences
WHERE video_hash = ? AND sentence_index = ?
""",
(normalized_hash, sentence_index + 1),
)
# Compact following indexes upward so sentence_index stays contiguous.
following = [
row["sentence_index"]
for row in connection.execute(
"""
SELECT sentence_index FROM sentences
WHERE video_hash = ? AND sentence_index > ?
ORDER BY sentence_index ASC
""",
(normalized_hash, sentence_index + 1),
).fetchall()
]
for old_index in following:
connection.execute(
"""
UPDATE sentences SET sentence_index = ?
WHERE video_hash = ? AND sentence_index = ?
""",
(old_index - 1, normalized_hash, old_index),
)
connection.execute(
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
(utc_now(), normalized_hash),
)
row = connection.execute(
"""
SELECT * FROM sentences
WHERE video_hash = ? AND sentence_index = ?
""",
(normalized_hash, sentence_index),
).fetchone()
return _sentence_from_row(row)
def adjust_sentence_boundary(
self,
@@ -647,17 +972,83 @@ class VideoRepository:
(normalized_hash, *updated_indexes),
).fetchall()
return [
SentenceBoundary(
index=row["sentence_index"],
start_ms=row["start_ms"],
end_ms=row["end_ms"],
text=row["text"],
language=row["language"],
reference_speech_duration_ms=row["reference_speech_duration_ms"],
return [_sentence_from_row(row) for row in result_rows]
def get_translation_glossary(self, video_hash: str) -> List[Tuple[str, str]]:
video = self.get_video(video_hash)
if video is None:
return []
return _parse_translation_glossary(video.get("translation_glossary"))
def save_translation_glossary(
self,
video_hash: str,
terms: Sequence[Any],
) -> bool:
normalized_hash = normalize_video_hash(video_hash)
payload = json.dumps(
[
{"source": source, "zh": zh}
for source, zh in _normalize_translation_glossary(terms)
],
ensure_ascii=False,
)
for row in result_rows
]
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE videos
SET translation_glossary = ?, updated_at = ?
WHERE video_hash = ?
""",
(payload, utc_now(), normalized_hash),
)
return cursor.rowcount > 0
def clear_sentence_translations(
self,
video_hash: str,
indexes: Optional[Sequence[int]] = None,
) -> None:
normalized_hash = normalize_video_hash(video_hash)
with self._connect() as connection:
if indexes is None:
connection.execute(
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
(normalized_hash,),
)
return
unique_indexes = list(dict.fromkeys(indexes))
if not unique_indexes:
return
connection.executemany(
"""
UPDATE sentences SET zh_text = NULL
WHERE video_hash = ? AND sentence_index = ?
""",
[(normalized_hash, index) for index in unique_indexes],
)
def save_sentence_translations(
self,
video_hash: str,
translations: Dict[int, str],
) -> Optional[SentenceBoundaryDocument]:
normalized_hash = normalize_video_hash(video_hash)
with self._connect() as connection:
for index, zh_text in translations.items():
connection.execute(
"""
UPDATE sentences
SET zh_text = ?
WHERE video_hash = ? AND sentence_index = ?
""",
(zh_text.strip(), normalized_hash, index),
)
connection.execute(
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
(utc_now(), normalized_hash),
)
return self.get_document(normalized_hash)
def create_dub_share(
self,
@@ -789,6 +1180,43 @@ class VideoRepository:
return None
return self.get_user_by_id(user_id)
def create_sso_user(
self,
*,
username: str,
nickname: str,
email: str = "",
sso_provider: str = "d1kt",
) -> Optional[Dict[str, Any]]:
user_id = uuid.uuid4().hex
now = utc_now()
try:
with self._connect() as connection:
connection.execute(
"""
INSERT INTO users (id, username, nickname, phone, sso_provider, phone_bound, password_hash, created_at)
VALUES (?, ?, ?, NULL, ?, 0, '', ?)
""",
(
user_id,
username.strip().lower(),
(nickname or username).strip()[:80],
sso_provider,
now,
),
)
except sqlite3.IntegrityError:
return None
return self.get_user_by_id(user_id)
def bind_phone(self, user_id: str, phone: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
connection.execute(
"UPDATE users SET phone = ?, phone_bound = 1 WHERE id = ?",
(phone.strip()[:20], user_id),
)
return self.get_user_by_id(user_id)
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
@@ -805,6 +1233,25 @@ class VideoRepository:
).fetchone()
return dict(row) if row else None
def list_users(self) -> List[Dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT u.id, u.username, u.nickname, u.phone, u.created_at,
COUNT(DISTINCT e.video_hash) AS enrollment_count
FROM users u
LEFT JOIN enrollments e ON e.user_id = u.id
GROUP BY u.id
ORDER BY u.created_at DESC
"""
).fetchall()
return [dict(row) for row in rows]
def delete_user(self, user_id: str) -> bool:
with self._connect() as connection:
cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,))
return cursor.rowcount > 0
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
@@ -815,6 +1262,22 @@ class VideoRepository:
return None
return self.get_user_by_id(row["id"])
def set_user_password(self, username: str, password: str) -> bool:
"""Set a local password for either a normal or SSO-linked account."""
username = username.strip()
if not username:
return False
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE users
SET password_hash = ?
WHERE username = ? COLLATE NOCASE
""",
(hash_password(password), username),
)
return cursor.rowcount > 0
def create_session(self, user_id: str) -> str:
token = new_token()
now = datetime.now(timezone.utc)
@@ -949,6 +1412,47 @@ class VideoRepository:
).fetchall()
return [dict(row) for row in rows]
def delete_user_result(self, user_id: str, attempt_id: str) -> Optional[Dict[str, Any]]:
"""Delete one attempt owned by the user and return its stored audio filename."""
with self._connect() as connection:
row = connection.execute(
"""
SELECT audio_filename FROM attempts
WHERE attempt_id = ? AND user_id = ?
""",
(attempt_id, user_id),
).fetchone()
if row is None:
return None
connection.execute(
"DELETE FROM attempts WHERE attempt_id = ? AND user_id = ?",
(attempt_id, user_id),
)
return {"audio_filename": row["audio_filename"]}
def delete_user_dub_share(self, user_id: str, share_id: str) -> Optional[List[str]]:
"""Delete one dub share owned by the user and return stored audio filenames."""
with self._connect() as connection:
share = connection.execute(
"SELECT share_id FROM dub_shares WHERE share_id = ? AND user_id = ?",
(share_id, user_id),
).fetchone()
if share is None:
return None
filenames = [
row["audio_filename"]
for row in connection.execute(
"SELECT audio_filename FROM dub_share_segments WHERE share_id = ?",
(share_id,),
).fetchall()
]
# Segments are removed by ON DELETE CASCADE.
connection.execute(
"DELETE FROM dub_shares WHERE share_id = ? AND user_id = ?",
(share_id, user_id),
)
return filenames
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
video = self.get_video(video_hash)
if video is None:
@@ -959,3 +1463,120 @@ class VideoRepository:
(normalize_video_hash(video_hash),),
)
return video
def _english_fingerprint(document: SentenceBoundaryDocument) -> tuple:
return tuple(
(sentence.index, sentence.start_ms, sentence.end_ms, (sentence.text or "").strip())
for sentence in document.sentences
)
def _sentence_from_row(row: sqlite3.Row) -> SentenceBoundary:
keys = set(row.keys())
zh_text = row["zh_text"] if "zh_text" in keys else None
return SentenceBoundary(
index=row["sentence_index"],
start_ms=row["start_ms"],
end_ms=row["end_ms"],
text=row["text"],
language=row["language"],
zh_text=zh_text,
reference_speech_duration_ms=row["reference_speech_duration_ms"],
)
def _join_adjacent_sentence_text(
left: str,
right: str,
language: Optional[str],
) -> str:
"""Join two adjacent sentence texts with language-aware spacing."""
left_text = left.strip()
right_text = right.strip()
if not left_text:
return right_text
if not right_text:
return left_text
lang = (language or "").strip().lower()
if lang.startswith(("zh", "ja", "ko")):
return f"{left_text}{right_text}"
left_end = left_text[-1]
right_start = right_text[0]
if _is_cjk_char(left_end) or _is_cjk_char(right_start):
return f"{left_text}{right_text}"
if left_end.isspace() or right_start in ",.;:!?)]}、,。!?;:”’》〉】":
return f"{left_text}{right_text}"
return f"{left_text} {right_text}"
def _is_cjk_char(char: str) -> bool:
code = ord(char)
return (
0x3400 <= code <= 0x4DBF
or 0x4E00 <= code <= 0x9FFF
or 0xF900 <= code <= 0xFAFF
or 0x20000 <= code <= 0x2CEAF
)
def _moss_segment_row(video_hash: str, index: int, segment: Dict[str, Any]) -> tuple:
start_seconds = float(segment["start_seconds"])
end_seconds = float(segment["end_seconds"])
words = segment.get("words")
words_json = json.dumps(words, ensure_ascii=False) if words else None
speaker = segment.get("speaker")
return (
video_hash,
index,
start_seconds,
end_seconds,
int(round(start_seconds * 1000)),
int(round(end_seconds * 1000)),
str(segment.get("text") or "").strip(),
str(speaker) if speaker is not None else None,
words_json,
)
def _normalize_translation_glossary(terms: Sequence[Any]) -> List[Tuple[str, str]]:
cleaned: List[Tuple[str, str]] = []
seen: Dict[str, int] = {}
for item in terms or []:
source = ""
zh = ""
if isinstance(item, dict):
source = str(item.get("source") or "")
zh = str(item.get("zh") or "")
elif isinstance(item, (tuple, list)) and len(item) >= 2:
source = str(item[0] or "")
zh = str(item[1] or "")
source = source.strip()[:120]
zh = zh.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 _parse_translation_glossary(raw: Any) -> List[Tuple[str, str]]:
if not raw:
return []
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
return _normalize_translation_glossary(raw)

View File

@@ -81,7 +81,7 @@ table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e3e7ea; vertical-align: middle; }
th { position: sticky; top: 0; z-index: 1; color: #44515b; background: #edf1f3; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.actions-column { min-width: 280px; }
.actions-column { min-width: 340px; }
.actions { display: flex; gap: 6px; white-space: nowrap; }
.actions button, .actions a { min-height: 30px; padding: 5px 9px; font-size: 12px; }
.actions a { display: inline-flex; align-items: center; border: 1px solid #aab4bc; border-radius: 4px; color: #26323c; text-decoration: none; }
@@ -103,6 +103,9 @@ dialog {
dialog::backdrop { background: rgb(20 25 28 / 55%); }
.dialog-heading { padding: 16px 18px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dce1e5; }
.dialog-heading span { font-size: 12px; color: #68747d; }
.dialog-heading-actions { display: flex; align-items: center; gap: 8px; }
.gap-row td { background: #fff8ee; color: #9a5b12; font-style: italic; }
.missing-zh-row .zh-text { color: #a45d08; }
.sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; }
.sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; }
.sentence-scroll select { min-width: 90px; }
@@ -119,3 +122,11 @@ dialog::backdrop { background: rgb(20 25 28 / 55%); }
#upload-form { grid-template-columns: 1fr; }
.actions-column { min-width: 250px; }
}
.zh-text { margin-top: 6px; color: #66727d; font-size: 12px; white-space: pre-wrap; line-height: 1.45; }
.retranslate-form { padding: 16px 18px 20px; display: grid; gap: 12px; }
.retranslate-form p { margin: 0; font-size: 13px; color: #4f5b64; line-height: 1.55; }
.retranslate-form label { display: grid; gap: 6px; font-size: 13px; color: #4f5b64; }
.retranslate-form textarea { width: 100%; min-height: 160px; resize: vertical; }
.retranslate-form code { font-size: 12px; }
.retranslate-actions { display: flex; justify-content: flex-end; }

View File

@@ -71,15 +71,63 @@
</div>
<div id="empty-state" class="empty-state" hidden>暂无视频</div>
</section>
<section class="video-band" aria-labelledby="users-heading">
<div class="section-heading">
<h2 id="users-heading">用户管理</h2>
<span id="user-count">0 个用户</span>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>用户名</th>
<th>姓名</th>
<th>手机号</th>
<th>选修课程</th>
<th>注册时间</th>
<th class="actions-column">操作</th>
</tr>
</thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<div id="user-empty-state" class="empty-state" hidden>暂无用户</div>
</section>
</main>
<dialog id="retranslate-dialog">
<div class="dialog-heading">
<div>
<h2 id="retranslate-title">重新翻译中文字幕</h2>
<span id="retranslate-meta"></span>
</div>
<button id="close-retranslate-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
</div>
<form id="retranslate-form" class="retranslate-form">
<p>会清空当前中文字幕并重新翻译。可指定专有名词译法,每行一条,格式为 <code>原文=中文</code>。例如 <code>Harimoto=哈瑞么托</code>,也可以把错误译名写在左边:<code>哈达马托=哈瑞么托</code>。</p>
<label>
<span>专有名词译法</span>
<textarea id="retranslate-terms" placeholder="Harimoto=哈瑞么托"></textarea>
</label>
<div class="retranslate-actions">
<button id="repair-translations" type="button">只补译缺失</button>
<button class="primary" type="submit">开始重新翻译</button>
</div>
</form>
</dialog>
<dialog id="sentence-dialog">
<div class="dialog-heading">
<div>
<h2 id="sentence-title">句子文本</h2>
<span id="sentence-meta"></span>
</div>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
<div class="dialog-heading-actions">
<button id="repair-gaps" type="button">修复空白</button>
<button id="repair-missing-zh" type="button">补译缺失</button>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭" title="关闭时批量处理暂存修改">X</button>
</div>
</div>
<div class="sentence-scroll">
<table>
@@ -91,6 +139,6 @@
</div>
</dialog>
<script src="/static/admin.js?v=admin-title-autofill-v2" defer></script>
<script src="/static/admin.js?v=admin-sentence-batch-v5" defer></script>
</body>
</html>

View File

@@ -1,6 +1,17 @@
const GAP_REPAIR_MIN_MS = 8000;
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
const state = {
adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
videos: [],
users: [],
retranslateVideo: null,
sentenceVideo: null,
sentenceDraft: null,
sentenceBaseMeta: "",
sentencePending: false,
sentenceSaving: false,
sentenceNeedsProcessing: false,
};
const elements = {
@@ -21,6 +32,18 @@ const elements = {
sentenceMeta: document.querySelector("#sentence-meta"),
sentenceRows: document.querySelector("#sentence-rows"),
closeDialog: document.querySelector("#close-dialog"),
repairGaps: document.querySelector("#repair-gaps"),
repairMissingZh: document.querySelector("#repair-missing-zh"),
retranslateDialog: document.querySelector("#retranslate-dialog"),
retranslateTitle: document.querySelector("#retranslate-title"),
retranslateMeta: document.querySelector("#retranslate-meta"),
retranslateForm: document.querySelector("#retranslate-form"),
retranslateTerms: document.querySelector("#retranslate-terms"),
repairTranslations: document.querySelector("#repair-translations"),
closeRetranslateDialog: document.querySelector("#close-retranslate-dialog"),
userRows: document.querySelector("#user-rows"),
userCount: document.querySelector("#user-count"),
userEmptyState: document.querySelector("#user-empty-state"),
};
elements.adminKey.value = state.adminKey;
@@ -29,8 +52,31 @@ elements.saveKey.addEventListener("click", () => {
localStorage.setItem("oralTrainerAdminKey", state.adminKey);
elements.serviceState.textContent = "密钥已保存";
});
elements.refresh.addEventListener("click", loadVideos);
elements.refresh.addEventListener("click", () => {
loadVideos();
loadUsers();
});
elements.closeDialog.addEventListener("click", () => elements.dialog.close());
elements.dialog.addEventListener("close", handleSentenceDialogClose);
elements.repairGaps.addEventListener("click", () => {
if (state.sentencePending) {
elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理";
return;
}
if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true });
});
elements.repairMissingZh.addEventListener("click", () => {
if (state.sentencePending) {
elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理";
return;
}
if (state.sentenceVideo) repairMissingTranslations(state.sentenceVideo, { reloadSentences: true });
});
elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
elements.repairTranslations.addEventListener("click", () => {
if (state.retranslateVideo) repairMissingTranslations(state.retranslateVideo, { closeRetranslate: true });
});
elements.retranslateForm.addEventListener("submit", submitRetranslate);
elements.uploadForm.addEventListener("submit", uploadVideo);
elements.videoFile.addEventListener("change", () => {
if (!elements.videoFile.files[0]) return;
@@ -90,6 +136,54 @@ function renderVideos() {
}
}
async function loadUsers() {
try {
const payload = await api("/api/v1/admin/users");
state.users = payload.users;
renderUsers();
} catch (error) {
elements.serviceState.textContent = `加载用户失败: ${error.message}`;
}
}
function renderUsers() {
elements.userRows.replaceChildren();
elements.userCount.textContent = `${state.users.length} 个用户`;
elements.userEmptyState.hidden = state.users.length > 0;
state.users.forEach((user) => {
const row = document.createElement("tr");
row.append(
textCell(user.username),
textCell(user.nickname),
textCell(user.phone || "—"),
textCell(String(user.enrollment_count ?? 0)),
textCell(formatDate(user.created_at)),
userActionCell(user),
);
elements.userRows.append(row);
});
}
function userActionCell(user) {
const cell = document.createElement("td");
cell.classList.add("actions-column");
cell.append(
button("删除", async () => {
const confirmed = window.confirm(
`确定要删除用户「${user.nickname || user.username}」吗?该操作不可恢复。`
);
if (!confirmed) return;
try {
await api(`/api/v1/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers();
} catch (error) {
elements.serviceState.textContent = `删除用户失败: ${error.message}`;
}
}),
);
return cell;
}
function textCell(value) {
const cell = document.createElement("td");
cell.textContent = value;
@@ -126,7 +220,10 @@ function actionCell(video) {
play.rel = "noopener";
play.textContent = "播放";
actions.append(play);
actions.append(button("改标题", () => renameVideo(video)));
actions.append(button("句子", () => openSentences(video)));
actions.append(button("修复空白", () => repairGaps(video)));
actions.append(button("重新翻译", () => openRetranslate(video)));
actions.append(button("重新处理", () => reprocess(video.video_hash)));
const remove = button("删除", () => deleteVideo(video));
remove.className = "danger";
@@ -143,6 +240,28 @@ function button(label, handler) {
return result;
}
async function renameVideo(video) {
const title = window.prompt("请输入新的课程标题", video.title || "");
if (title === null) return;
const cleanedTitle = title.trim();
if (!cleanedTitle) {
elements.serviceState.textContent = "课程标题不能为空";
return;
}
if (cleanedTitle === video.title) return;
try {
await api(`/api/v1/admin/videos/${video.video_hash}/title`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: cleanedTitle }),
});
elements.serviceState.textContent = "课程标题已更新";
await loadVideos();
} catch (error) {
elements.serviceState.textContent = `标题修改失败: ${error.message}`;
}
}
function uploadVideo(event) {
event.preventDefault();
const file = document.querySelector("#video-file").files[0];
@@ -192,25 +311,306 @@ function readXhrError(request) {
}
async function openSentences(video) {
if (state.sentenceSaving) {
elements.serviceState.textContent = "句子批量处理中,请稍后再打开";
return;
}
if (
state.sentenceVideo?.video_hash === video.video_hash &&
Array.isArray(state.sentenceDraft)
) {
renderSentenceDraft(video);
if (!elements.dialog.open) elements.dialog.showModal();
return;
}
try {
const detail = await api(`/api/v1/videos/${video.video_hash}`);
state.sentenceVideo = video;
elements.sentenceTitle.textContent = detail.video.title;
const sentences = detail.boundaries?.sentences || [];
elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`;
elements.sentenceRows.replaceChildren();
for (const sentence of sentences) {
elements.sentenceRows.append(sentenceRow(video, sentence));
}
state.sentenceDraft = (detail.boundaries?.sentences || []).map((sentence) => ({
...sentence,
originStartMs: sentence.start_ms,
originEndMs: sentence.end_ms,
sourceStartMs: undefined,
sourceEndMs: undefined,
}));
state.sentencePending = false;
state.sentenceSaving = false;
state.sentenceNeedsProcessing = false;
const durationMs = detail.video.duration_ms || 0;
const sentences = state.sentenceDraft;
const gaps = collectSentenceGaps(sentences, durationMs);
const missingZh = sentences.filter(translationNeedsRepair).length;
const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : "";
const zhLabel = missingZh ? ` / ${missingZh} 句未译` : "";
state.sentenceBaseMeta = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`;
renderSentenceDraft(video);
if (!elements.dialog.open) elements.dialog.showModal();
} catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`;
}
}
function sentenceRow(video, sentence) {
function renderSentenceDraft(video) {
const sentences = state.sentenceDraft || [];
const durationMs = state.sentenceVideo?.duration_ms || 0;
const gaps = collectSentenceGaps(sentences, durationMs);
const missingZh = sentences.filter(translationNeedsRepair).length;
elements.sentenceMeta.textContent = [
state.sentenceBaseMeta,
state.sentencePending ? "有暂存修改" : "",
state.sentenceNeedsProcessing ? "已入库待处理" : "",
state.sentenceSaving ? "正在处理" : "",
].filter(Boolean).join(" / ");
elements.repairGaps.disabled = gaps.length === 0 || state.sentencePending;
elements.repairMissingZh.disabled = missingZh === 0 || state.sentencePending;
elements.closeDialog.disabled = state.sentenceSaving;
elements.closeDialog.textContent = state.sentenceSaving ? "..." : "X";
elements.sentenceRows.replaceChildren();
let gapIndex = 0;
for (const sentence of sentences) {
if (gapIndex < gaps.length && gaps[gapIndex].end_ms === sentence.start_ms) {
elements.sentenceRows.append(gapRow(gaps[gapIndex]));
gapIndex += 1;
}
elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length));
}
if (gapIndex < gaps.length) {
elements.sentenceRows.append(gapRow(gaps[gapIndex]));
}
}
async function handleSentenceDialogClose() {
if (!state.sentenceDraft || state.sentenceSaving) return;
if (!state.sentencePending && !state.sentenceNeedsProcessing) {
resetSentenceDraft();
return;
}
if (state.sentencePending) {
const persisted = await saveSentenceDraft({ processTimings: false });
if (!persisted) {
return;
}
}
const processed = await saveSentenceDraft({ processTimings: true });
if (!processed) {
return;
}
resetSentenceDraft();
loadVideos();
}
async function saveSentenceDraft({ processTimings = false } = {}) {
const video = state.sentenceVideo;
if (!video) return false;
if (!processTimings && !state.sentencePending) return true;
if (processTimings && !state.sentencePending && !state.sentenceNeedsProcessing) return true;
materializeSentenceDraftSplits(video);
state.sentenceSaving = true;
renderSentenceDraft(video);
try {
const body = {
process_timings: processTimings,
sentences: state.sentenceDraft.map(({
index,
originStartMs,
originEndMs,
sourceStartMs,
sourceEndMs,
...sentence
}) => ({
...sentence,
...(sourceStartMs !== undefined && sourceEndMs !== undefined ? {
source_start_ms: sourceStartMs,
source_end_ms: sourceEndMs,
} : {}),
}),
),
};
const saved = await api(
`/api/v1/admin/videos/${video.video_hash}/sentences/batch`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const previousByRange = new Map(
state.sentenceDraft.map((sentence) => [`${sentence.start_ms}:${sentence.end_ms}`, sentence]),
);
state.sentenceDraft = saved.map((sentence) => {
const previous = previousByRange.get(`${sentence.start_ms}:${sentence.end_ms}`);
return {
...sentence,
originStartMs: previous?.originStartMs ?? sentence.start_ms,
originEndMs: previous?.originEndMs ?? sentence.end_ms,
sourceStartMs: previous?.sourceStartMs,
sourceEndMs: previous?.sourceEndMs,
};
});
state.sentencePending = false;
state.sentenceNeedsProcessing = !processTimings;
return true;
} catch (error) {
elements.serviceState.textContent = processTimings
? `句子批量处理失败: ${error.message}`
: `句子入库失败: ${error.message}`;
return false;
} finally {
state.sentenceSaving = false;
if (state.sentenceDraft) renderSentenceDraft(video);
}
}
function materializeSentenceDraftSplits(video) {
for (const sentence of [...(state.sentenceDraft || [])]) {
if (/\r?\n/.test(sentence.text || "")) {
splitDraftSentence(video, sentence, sentence.text, sentence.language);
}
}
}
function resetSentenceDraft() {
state.sentenceDraft = null;
state.sentencePending = false;
state.sentenceSaving = false;
state.sentenceNeedsProcessing = false;
state.sentenceVideo = null;
state.sentenceBaseMeta = "";
}
function markSentenceDraftChanged(video) {
state.sentencePending = true;
renderSentenceDraft(video);
}
function markSentenceDraftPending() {
state.sentencePending = true;
elements.sentenceMeta.textContent = [
state.sentenceBaseMeta,
"有暂存修改",
].filter(Boolean).join(" / ");
elements.repairGaps.disabled = true;
elements.repairMissingZh.disabled = true;
}
function joinDraftSentenceText(left, right, language) {
const leftText = (left || "").trim();
const rightText = (right || "").trim();
if (!leftText) return rightText;
if (!rightText) return leftText;
const lang = (language || "").toLowerCase();
if (lang.startsWith("zh") || lang.startsWith("ja") || lang.startsWith("ko")) {
return leftText + rightText;
}
const leftEnd = leftText.slice(-1);
const rightStart = rightText.slice(0, 1);
const cjk = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
if (cjk.test(leftEnd) || cjk.test(rightStart)) return leftText + rightText;
if (rightStart && ",.;:!?)]}、,。!?;:”’》〉】".includes(rightStart)) {
return leftText + rightText;
}
return `${leftText} ${rightText}`;
}
function splitDraftSentence(video, sentence, text, language) {
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const duration = Math.max(1, sentence.end_ms - sentence.start_ms);
const weights = lines.map((line) => Math.max(1, line.replace(/\s+/g, "").length));
const totalWeight = Math.max(1, weights.reduce((total, weight) => total + weight, 0));
const sourceStart = sentence.sourceStartMs ?? sentence.originStartMs ?? sentence.start_ms;
const sourceEnd = sentence.sourceEndMs ?? sentence.originEndMs ?? sentence.end_ms;
const replacements = [];
let cursor = sentence.start_ms;
let remaining = lines.length;
let weightSum = 0;
for (const [lineIndex, line] of lines.entries()) {
const isLast = lineIndex === lines.length - 1;
weightSum += weights[lineIndex];
let end = sentence.end_ms;
if (!isLast) {
end = sentence.start_ms + Math.round(duration * weightSum / totalWeight);
end = Math.min(Math.max(end, cursor + 1), sentence.end_ms - (remaining - 1));
}
const sourceDuration = Math.max(
1,
(sentence.reference_speech_duration_ms || duration) * weights[lineIndex] / totalWeight,
);
replacements.push({
index: sentence.index + lineIndex,
start_ms: cursor,
end_ms: end,
text: line,
language: language || sentence.language,
zh_text: null,
reference_speech_duration_ms: Math.max(1, Math.round(sourceDuration)),
sourceStartMs: sourceStart,
sourceEndMs: sourceEnd,
originStartMs: sourceStart,
originEndMs: sourceEnd,
});
cursor = end;
remaining -= 1;
}
state.sentenceDraft.splice(sentence.index, 1, ...replacements);
renumberSentenceDraft();
markSentenceDraftChanged(video);
}
function renumberSentenceDraft() {
(state.sentenceDraft || []).forEach((sentence, index) => {
sentence.index = index;
});
}
function collectSentenceGaps(sentences, durationMs, minGapMs = GAP_REPAIR_MIN_MS) {
const gaps = [];
let cursor = 0;
for (const sentence of sentences) {
if (sentence.start_ms - cursor >= minGapMs) {
gaps.push({ start_ms: cursor, end_ms: sentence.start_ms });
}
cursor = Math.max(cursor, sentence.end_ms);
}
const endMs = durationMs || cursor;
if (endMs - cursor >= minGapMs) {
gaps.push({ start_ms: cursor, end_ms: endMs });
}
return gaps;
}
function gapRow(gap) {
const row = document.createElement("tr");
row.className = "gap-row";
row.append(
textCell(""),
textCell(`${formatDuration(gap.start_ms)} — ${formatDuration(gap.end_ms)}`),
textCell(`未识别空白 ${formatDuration(gap.end_ms - gap.start_ms)}`),
textCell(""),
textCell(""),
);
return row;
}
function translationNeedsRepair(sentence) {
const source = (sentence.text || "").trim();
const zh = (sentence.zh_text || "").trim();
if (!source) return false;
if (!zh) return true;
if (CJK_RE.test(zh)) return false;
return !CJK_RE.test(source);
}
function sentenceRow(video, sentence, totalCount) {
const row = document.createElement("tr");
if (translationNeedsRepair(sentence)) row.className = "missing-zh-row";
const textArea = document.createElement("textarea");
textArea.value = sentence.text || "";
textArea.addEventListener("input", () => {
sentence.text = textArea.value;
sentence.zh_text = null;
markSentenceDraftPending();
});
const language = document.createElement("select");
for (const [value, label] of [["", "自动"], ["en", "英语"], ["zh", "中文"]]) {
const option = document.createElement("option");
@@ -219,39 +619,81 @@ function sentenceRow(video, sentence) {
option.selected = value === (sentence.language || "");
language.append(option);
}
language.addEventListener("change", () => {
sentence.language = language.value || null;
markSentenceDraftPending();
});
const textContainer = document.createElement("td");
textContainer.append(textArea);
const zhNote = document.createElement("div");
zhNote.className = "zh-text";
zhNote.textContent = translationNeedsRepair(sentence)
? (sentence.zh_text ? `仍是英文:${sentence.zh_text}` : "暂无中文翻译")
: sentence.zh_text;
textContainer.append(textArea, zhNote);
const languageContainer = document.createElement("td");
languageContainer.append(language);
const saveContainer = document.createElement("td");
const save = button("保存", async () => {
save.disabled = true;
save.textContent = "保存中";
try {
const body = JSON.stringify({ text: textArea.value, language: language.value || null });
if (/\r?\n/.test(textArea.value)) {
await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/split`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
});
save.textContent = "已拆分";
await openSentences(video);
if (/\r?\n/.test(sentence.text || "")) {
splitDraftSentence(video, sentence, sentence.text, language.value || null);
} else {
await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
});
save.textContent = "已保存";
sentence.text = textArea.value;
sentence.language = language.value || null;
sentence.zh_text = null;
markSentenceDraftChanged(video);
}
} catch (error) {
elements.serviceState.textContent = `句子保存失败: ${error.message}`;
save.textContent = error.message;
await saveSentenceDraft({ processTimings: false });
} finally {
window.setTimeout(() => { save.disabled = false; save.textContent = "保存"; }, 1600);
save.disabled = false;
save.textContent = "保存";
}
});
saveContainer.append(save);
if (sentence.index < totalCount - 1) {
const merge = button("合并下句", async () => {
const preview = `${(sentence.text || "").trim()} + 下一句`;
const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`);
if (!confirmed) return;
const sourceStart = sentence.start_ms;
const sourceEnd = sentence.end_ms;
merge.disabled = true;
merge.textContent = "入库中";
try {
if (state.sentencePending) {
const persisted = await saveSentenceDraft({ processTimings: false });
if (!persisted) return;
}
const targetIndex = state.sentenceDraft.findIndex(
(item) => item.start_ms === sourceStart && item.end_ms === sourceEnd,
);
if (targetIndex < 0) throw new Error("找不到要合并的句子");
const merged = await api(
`/api/v1/admin/videos/${video.video_hash}/sentences/${targetIndex}/merge`,
{ method: "PUT" },
);
state.sentenceDraft.splice(targetIndex, 2, {
...merged,
originStartMs: merged.start_ms,
originEndMs: merged.end_ms,
sourceStartMs: undefined,
sourceEndMs: undefined,
});
renumberSentenceDraft();
state.sentencePending = false;
state.sentenceNeedsProcessing = true;
} catch (error) {
elements.serviceState.textContent = `句子合并失败: ${error.message}`;
} finally {
merge.disabled = false;
merge.textContent = "合并下句";
renderSentenceDraft(video);
}
});
saveContainer.append(merge);
}
const timeContainer = document.createElement("td");
const startLabel = document.createElement("span");
startLabel.textContent = formatDuration(sentence.start_ms);
@@ -275,41 +717,141 @@ function boundaryControl(video, sentence) {
const status = document.createElement("span");
status.className = "boundary-status";
async function adjust(deltaMs, changedButton) {
const buttons = [minus, plus];
buttons.forEach((item) => { item.disabled = true; });
status.textContent = "";
try {
await api(
`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/boundary`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delta_ms: deltaMs }),
},
);
await openSentences(video);
} catch (error) {
elements.serviceState.textContent = `分割点调整失败: ${error.message}`;
status.textContent = "失败";
buttons.forEach((item) => { item.disabled = false; });
window.setTimeout(() => { status.textContent = ""; }, 1800);
}
if (changedButton) {
window.setTimeout(() => {
changedButton.textContent = deltaMs < 0 ? "−0.1" : "+0.1";
}, 900);
async function adjust(deltaMs) {
const next = state.sentenceDraft[sentence.index + 1];
if (!next) return;
const nextBoundary = sentence.end_ms + deltaMs;
if (
nextBoundary <= sentence.start_ms ||
nextBoundary >= next.end_ms
) {
elements.serviceState.textContent = "分割点必须保留在两句时间范围内";
return;
}
sentence.end_ms = nextBoundary;
next.start_ms = nextBoundary;
sentence.sourceStartMs = undefined;
sentence.sourceEndMs = undefined;
next.sourceStartMs = undefined;
next.sourceEndMs = undefined;
markSentenceDraftChanged(video);
await saveSentenceDraft({ processTimings: false });
}
const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100, minus));
const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100, plus));
const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100));
const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100));
minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒";
plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒";
container.append(minus, label, plus, status);
return container;
}
async function openRetranslate(video) {
state.retranslateVideo = video;
elements.retranslateTitle.textContent = `重新翻译:${video.title}`;
elements.retranslateMeta.textContent = video.video_hash;
elements.retranslateTerms.value = "";
try {
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`);
elements.retranslateTerms.value = formatTranslationTerms(payload.terms || []);
const missing = payload.missing_count || 0;
const status = missing
? `${missing} 句未译`
: (payload.complete ? "中文字幕已就绪" : (payload.status === "running" ? "正在翻译" : "尚未翻译完成"));
elements.retranslateMeta.textContent = `${status} / ${video.video_hash}`;
elements.repairTranslations.disabled = missing === 0;
} catch (error) {
elements.serviceState.textContent = `加载翻译信息失败: ${error.message}`;
}
if (!elements.retranslateDialog.open) elements.retranslateDialog.showModal();
}
function formatTranslationTerms(terms) {
return (terms || []).map((item) => `${item.source}=${item.zh}`).join("\n");
}
function parseTranslationTerms(text) {
const terms = [];
for (const rawLine of (text || "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = line.match(/^(.+?)\s*(?:=|=>|:|:)\s*(.+)$/);
if (!match) {
throw new Error(`无法解析专有名词:${line}`);
}
const source = match[1].trim();
const zh = match[2].trim();
if (!source || !zh) {
throw new Error(`无法解析专有名词:${line}`);
}
terms.push({ source, zh });
}
return terms;
}
async function submitRetranslate(event) {
event.preventDefault();
const video = state.retranslateVideo;
if (!video) return;
let terms;
try {
terms = parseTranslationTerms(elements.retranslateTerms.value);
} catch (error) {
elements.serviceState.textContent = error.message;
return;
}
const submit = elements.retranslateForm.querySelector("button[type='submit']");
submit.disabled = true;
try {
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ terms }),
});
const status = payload.complete ? "中文字幕已重新翻译" : "已开始重新翻译";
elements.serviceState.textContent = terms.length ? `${status},已应用 ${terms.length} 条专有名词` : status;
elements.retranslateDialog.close();
await loadVideos();
} catch (error) {
elements.serviceState.textContent = `重新翻译失败: ${error.message}`;
} finally {
submit.disabled = false;
}
}
async function repairMissingTranslations(video, options = {}) {
const confirmed = window.confirm(
`将只补译“${video.title}”里缺失或仍是英文的中文,已有中文会保留。继续?`,
);
if (!confirmed) return;
const buttons = [elements.repairMissingZh, elements.repairTranslations].filter(Boolean);
buttons.forEach((button) => { button.disabled = true; });
elements.serviceState.textContent = "正在补译缺失的中文…";
try {
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation/repair`, {
method: "POST",
});
const attempted = payload.attempted_count || 0;
if (!attempted) {
elements.serviceState.textContent = "没有需要补译的句子";
} else if (payload.complete) {
elements.serviceState.textContent = `补译完成:处理了 ${attempted} 句`;
} else {
const remaining = payload.missing_count ? `,还剩 ${payload.missing_count} 句` : "";
elements.serviceState.textContent = `已开始补译 ${attempted} 句${remaining},请稍后刷新`;
}
if (options.closeRetranslate) elements.retranslateDialog.close();
await loadVideos();
if (options.reloadSentences) await openSentences(video);
} catch (error) {
elements.serviceState.textContent = `补译失败: ${error.message}`;
} finally {
if (!options.reloadSentences) {
buttons.forEach((button) => { button.disabled = false; });
}
}
}
async function reprocess(videoHash) {
try {
await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" });
@@ -319,6 +861,36 @@ async function reprocess(videoHash) {
}
}
async function repairGaps(video, options = {}) {
const confirmed = window.confirm(
`将对“${video.title}”中间隔超过 8 秒的空白重新识别。现有句子会保留,可能需要几分钟。继续?`,
);
if (!confirmed) return;
const button = elements.repairGaps;
if (button) button.disabled = true;
elements.serviceState.textContent = "正在重新识别空白段…";
try {
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/repair-gaps`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ min_gap_ms: GAP_REPAIR_MIN_MS }),
});
if (payload.inserted_count) {
elements.serviceState.textContent = `空白修复完成:补了 ${payload.inserted_count} 句(${payload.transcribed_gaps}/${payload.gap_count} 段)`;
} else if (payload.gap_count) {
elements.serviceState.textContent = `发现 ${payload.gap_count} 段空白,但没有识别到新句子`;
} else {
elements.serviceState.textContent = "没有发现需要修复的空白段";
}
await loadVideos();
if (options.reloadSentences) await openSentences(video);
} catch (error) {
elements.serviceState.textContent = `空白修复失败: ${error.message}`;
} finally {
if (button && !options.reloadSentences) button.disabled = false;
}
}
async function deleteVideo(video) {
if (!window.confirm(`删除“${video.title}”?`)) return;
try {
@@ -352,6 +924,7 @@ function formatDate(value) {
checkHealth();
loadVideos();
loadUsers();
window.setInterval(() => {
if (!elements.dialog.open) loadVideos();
}, 5000);

View File

@@ -40,6 +40,7 @@ const statusText = document.getElementById('status');
const sentenceList = document.getElementById('sentences');
let dubAudio = new Audio();
dubAudio.preload = 'auto';
dubAudio.playbackRate = 1;
let segments = [];
let selectedIndex = 0;
let mode = null;
@@ -58,39 +59,34 @@ function playOriginal() {
mode = 'original';
originalButton.classList.add('active');
video.muted = false;
playSelectedSentence();
const sentence = segments[selectedIndex];
if (!sentence) return;
video.currentTime = sentence.start_ms / 1000;
video.play().catch(() => {});
}
function syncDub() {
function highlightActiveSentence() {
const timeMs = video.currentTime * 1000;
const sentence = segments[selectedIndex];
if (sentence && timeMs >= sentence.end_ms + PLAY_EXTEND_MS) {
video.pause();
dubAudio.pause();
video.currentTime = (sentence.end_ms + PLAY_EXTEND_MS) / 1000;
return;
}
const active = (sentence && timeMs >= sentence.start_ms && timeMs < sentence.end_ms + PLAY_EXTEND_MS)
? sentence
const selected = segments[selectedIndex];
const active = (selected && timeMs >= selected.start_ms && timeMs < selected.end_ms + PLAY_EXTEND_MS)
? selected
: segments.find(item => timeMs >= item.start_ms && timeMs < item.end_ms);
document.querySelectorAll('.sentence').forEach((node, index) => {
node.classList.toggle('active-sentence', segments[index] === active);
});
if (mode !== 'dubbing') return;
if (!active) {
dubAudio.pause();
return;
return { timeMs, selected };
}
function onVideoTimeUpdate() {
const { timeMs, selected } = highlightActiveSentence();
if (!selected) return;
// Only bound the muted video window. Dubbing audio plays at its original speed/duration.
if (timeMs >= selected.end_ms + PLAY_EXTEND_MS) {
video.pause();
if (mode === 'original') {
video.currentTime = (selected.end_ms + PLAY_EXTEND_MS) / 1000;
}
const source = active.audio_url;
if (dubAudio.dataset.src !== source) {
dubAudio.dataset.src = source;
dubAudio.src = source;
}
const target = Math.max(0, timeMs - active.start_ms) / 1000;
if (Math.abs(dubAudio.currentTime - target) > 0.25 && Number.isFinite(target)) {
dubAudio.currentTime = target;
}
if (video.paused || dubAudio.paused) dubAudio.play().catch(() => {});
}
function playDubbing() {
@@ -98,17 +94,33 @@ function playDubbing() {
mode = 'dubbing';
dubbingButton.classList.add('active');
video.muted = true;
playSelectedSentence();
const sentence = segments[selectedIndex];
if (!sentence || !sentence.audio_url) return;
video.currentTime = sentence.start_ms / 1000;
dubAudio.pause();
dubAudio.playbackRate = 1;
dubAudio.src = sentence.audio_url;
const startBoth = () => {
try { dubAudio.currentTime = 0; } catch (_) {}
video.play().catch(() => {});
dubAudio.play().catch(() => {});
};
if (dubAudio.readyState >= 2) {
startBoth();
} else {
dubAudio.onloadeddata = () => {
dubAudio.onloadeddata = null;
startBoth();
};
dubAudio.load();
}
}
function playSelectedSentence() {
const sentence = segments[selectedIndex];
if (!sentence) return;
video.currentTime = sentence.start_ms / 1000;
dubAudio.dataset.src = '';
dubAudio.src = '';
video.play().catch(() => {});
}
dubAudio.addEventListener('ended', () => {
if (mode === 'dubbing') {
video.pause();
}
});
async function load() {
try {
@@ -154,10 +166,9 @@ async function load() {
});
originalButton.onclick = playOriginal;
dubbingButton.onclick = playDubbing;
video.addEventListener('timeupdate', syncDub);
video.addEventListener('play', () => { if (mode === 'dubbing') syncDub(); });
video.addEventListener('pause', () => dubAudio.pause());
video.addEventListener('seeked', syncDub);
video.addEventListener('timeupdate', onVideoTimeUpdate);
video.addEventListener('seeked', highlightActiveSentence);
// Keep dubbing audio independent: do not pause/seek it to force video alignment.
} catch (error) {
statusText.textContent = `加载失败:${error.message}`;
}

Some files were not shown because too many files have changed in this diff Show More