Compare commits

...

118 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
4ae77de307 add registration page with nickname/phone fields and fix lateinit crashes 2026-08-31 15:06:43 +08:00
f2042e638f add a js version controling 2026-08-31 14:18:12 +08:00
88811be98d optimized a interface 2026-08-31 14:08:11 +08:00
7e4c0f5f90 fix a bug about ios 2026-08-31 13:37:38 +08:00
637101bcdb added a user system 2026-08-30 17:07:55 +08:00
0b85a2ed57 refresh borders of sentences 2026-08-30 11:57:23 +08:00
7cdc637527 fixed a bug 2026-08-30 11:19:57 +08:00
0d0b82e095 allow ajust sentences 2026-08-30 11:07:50 +08:00
508a26ba02 fix bug 2026-08-30 09:39:50 +08:00
a99c0cfd17 admin can split a sentence into 2 sentences 2026-08-30 09:25:34 +08:00
abc052e42d change the method of splitting sentences 2026-08-30 09:06:01 +08:00
997bd9eed8 optimized playing process bar 2026-08-30 08:32:31 +08:00
856aa38026 add volcengine OSS support 2026-08-30 06:52:14 +08:00
6de1d4a3d0 ajust playing time 2026-08-29 22:22:24 +08:00
f93bb8d1ff fixed some bugs on ios 2026-08-26 18:56:04 +08:00
bb677ea8cf optimize the appicon of iOS 2026-08-26 17:14:59 +08:00
76b0332035 add docs 2026-08-26 11:49:02 +08:00
e470fa2dae fixed again 2026-08-26 11:33:39 +08:00
b1d3a3bd5a continue fixing 2026-08-26 11:22:55 +08:00
a6297e2f9f fixed a bug 2026-08-26 10:46:09 +08:00
1b6090e44c fixed a bug 2026-08-26 10:25:07 +08:00
14d9c56d34 fixed a bug 2026-08-26 10:06:19 +08:00
f80da8a5f6 added share function 2026-08-26 09:47:52 +08:00
ccd8ec16bb fixed a bug 2026-08-26 09:08:38 +08:00
9feb876b7a added saving recorded sound 2026-08-26 08:33:43 +08:00
28674def97 fixed some bugs 2026-08-24 14:25:31 +08:00
8d4a81b4f5 add icon description 2026-08-21 20:46:38 +08:00
3f86e8f44e continue fixing 2026-08-18 21:57:25 +08:00
aa8baab7c0 fixed a gain 2026-08-18 21:27:20 +08:00
7556fd0a7e ajust time span 2026-08-18 21:16:43 +08:00
1fb3146590 fixed a bug 2026-08-18 20:54:42 +08:00
f5c6717186 change sentence cut method 2026-08-18 20:34:58 +08:00
a36e776343 fff 2026-08-18 09:08:21 +08:00
7c81976b2b added ios module 2026-08-17 20:13:28 +08:00
a8031e2e3b add bind ips 2026-08-17 14:33:50 +08:00
d27c2e229e beautified some ui 2026-08-17 14:28:37 +08:00
624b834632 change the url 2026-08-17 10:53:54 +08:00
c50c82a90f fixed nginx 2026-08-17 09:33:11 +08:00
47ca72a62a fixed docs 2026-08-17 09:26:42 +08:00
c8581c72b4 ffff 2026-08-16 17:48:41 +08:00
773f14751f fix docs 2026-08-16 16:50:34 +08:00
c90df866c1 modified nginx mode 2026-08-16 16:29:13 +08:00
149 changed files with 26681 additions and 912 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ sentence_api/data/oral_trainer.sqlite3*
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
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
“按句跳转”的开关状态同样会自动保存。
“按句跳转”和“中文字幕”的开关状态同样会自动保存。句边界就绪后,原文会叠在
视频画面底部;打开中文字幕时会向服务器请求翻译并一并显示。
## 常见问题
@@ -122,6 +134,12 @@ SDK 基于 AndroidX Media3 / ExoPlayer,支持在线视频流、本地缓存、
详见 [android/README.md](android/README.md)。
## iOS App
`ios/` 子工程是与安卓端对齐的 iOS 移动端(SwiftUI + AVPlayer):
在线课程播放、按句跳转、变速、横屏全屏、本地视频导入,以及麦克风录音
朗读评测,接口与安卓端完全一致。详见 [ios/README.md](ios/README.md)。
## 口语训练视频服务
`sentence_api/` 已扩展为完整的视频服务:支持超过 1 GB 的流式上传、MOSS 自动

View File

@@ -24,6 +24,22 @@ the course service or extracted from subtitle/speech analysis data.
These defaults match the desktop player's arrow-key workflow while fitting a
tablet touch screen.
## Sentence Playback Modes
`PlayerConfig.continuousPlayback` controls how playback crosses sentence
boundaries:
- `true` (default): the video plays continuously through all sentences.
- `false`: playback stays on the current sentence; when its boundary is
reached, playback loops back to the start of the same sentence until the
student swipes to the previous/next sentence.
Toggle it at runtime with `controller.setContinuousPlayback(enabled)`.
`controller.playCurrentSentenceAndStop()` plays only the current sentence and
automatically pauses once it finishes — useful for a "play the reference
sentence" button in a recording test flow.
## Basic Integration
```kotlin
@@ -68,7 +84,7 @@ OralTrainerSdk.init(
## Sentence Boundary API
The SDK defaults to `https://video_service.d1kt.cn` and requests:
The SDK defaults to `https://videoservice.d1kt.cn` and requests:
```text
GET /api/v1/videos/{sha256}/sentence-boundaries
@@ -93,7 +109,7 @@ Override the service only when a staging or private deployment is required:
OralTrainerSdk.init(
context,
OralTrainerSdkConfig(
sentenceBoundaryApiBaseUrl = "https://video_service.d1kt.cn",
sentenceBoundaryApiBaseUrl = "https://videoservice.d1kt.cn",
assessmentApiKey = BuildConfig.ORAL_TRAINER_CLIENT_KEY,
)
)
@@ -123,6 +139,63 @@ sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
})
```
## Sample App Configuration
The sample app points at the cloud service through the `SERVER_BASE_URL`
constant at the top of `MainActivity.kt`:
```kotlin
const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
```
Change it to your real deployment address before building the APK. For a LAN
or development server, an `http://<server-ip>:<port>` address works because the
sample app permits cleartext HTTP (see
`res/xml/network_security_config.xml`); production should keep HTTPS.
The sample app also enables `allowHttpFallback`: if an `https://` request fails
with a TLS error (for example a misconfigured or unstable gateway/certificate),
the SDK automatically retries the same request over plain HTTP. This is a
convenience for private deployments only — fix the TLS edge in production.
The app has two modules, switched by the `训练` / `测试` tabs at the top:
- `训练`: browse the cloud course list and do sentence-by-sentence playback
practice. The old built-in "示例课程" card was removed; only courses served
by `GET /api/v1/videos` appear here.
- `测试`: record your reading of the current sentence and submit it to the
server's assessment endpoint for AI scoring. Only cloud courses are
supported (the server must know the video hash and reference sentences).
The video's bottom-right corner has a small rotation icon that forces landscape
or portrait orientation, so rotation works even when the device's auto-rotate
switch is turned off. The progress bar below the sentence panel is draggable,
so the student can seek to any position by dragging it.
For the test module, fill in the client key that the server expects in the
`X-Client-Key` header (the `CLIENT_API_KEY` value from the server's
`sentence_api/.env`):
```kotlin
const val ASSESSMENT_API_KEY = "replace-with-your-client-api-key"
```
The app requests microphone permission at runtime on first use.
### Troubleshooting "云端暂不可用"
That label means `GET /api/v1/videos` failed. Since the fix, the sample app
shows the underlying error next to the label (for example DNS resolution,
timeout, TLS, or an unexpected HTTP status). Common causes:
- The APK was built with the wrong `SERVER_BASE_URL` for your deployment.
- The phone cannot reach the domain/IP (check with a browser on the phone, and
verify the server with `curl https://<server>/api/v1/videos`).
- The gateway only exposes plain HTTP, so the app must use `http://...` instead
of `https://...`.
- The server certificate is invalid/self-signed on the phone; use HTTP for
internal testing or install a valid certificate at the gateway.
## Local Video Testing
The computer path `/Users/...` is not visible to an Android device. For a

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

@@ -0,0 +1,48 @@
package cn.learningpad.oraltrainer.sdk
import android.net.Uri
import javax.net.ssl.SSLException
/**
* Runs one blocking request against [baseUrl]. When [OralTrainerSdkConfig.allowHttpFallback]
* is enabled and the HTTPS attempt fails with a TLS error (broken certificates or
* misconfigured TLS termination at the gateway), retries the same request over plain HTTP.
* This is intended for private deployments; production should fix the TLS edge instead.
*/
internal class HttpFallback private constructor(
private val allowFallback: Boolean,
) {
fun <T> execute(baseUrl: String, block: (String) -> T): T {
try {
return block(baseUrl)
} catch (error: Throwable) {
if (!allowFallback || !isTlsFailure(error)) {
throw error
}
val uri = Uri.parse(baseUrl)
if (uri.scheme != "https") {
throw error
}
val httpUrl = uri.buildUpon().scheme("http").build().toString()
return block(httpUrl)
}
}
private fun isTlsFailure(error: Throwable): Boolean {
var current: Throwable? = error
while (current != null) {
val message = current.message.orEmpty()
if (current is SSLException || message.contains("SSL", ignoreCase = true)) {
return true
}
current = current.cause
}
return false
}
companion object {
fun forConfig(config: OralTrainerSdkConfig): HttpFallback {
return HttpFallback(config.allowHttpFallback)
}
}
}

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
@@ -28,6 +31,17 @@ class OralTrainerController internal constructor(
private val mainHandler = Handler(Looper.getMainLooper())
private var released = false
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
@@ -42,9 +56,10 @@ class OralTrainerController internal constructor(
if (released) {
return
}
enforceStopAtSentenceEnd()
notifySentenceIfChanged()
notifySnapshot()
mainHandler.postDelayed(this, 250L)
mainHandler.postDelayed(this, IDLE_POLL_MS)
}
}
@@ -54,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()
}
@@ -68,7 +116,14 @@ class OralTrainerController internal constructor(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
lastSentenceIndex = null
if (updatingPlaybackWindow) {
notifySnapshot()
return
}
if (clippedSentence == null) {
baseItemIndex = player.currentMediaItemIndex
lastSentenceIndex = null
}
listeners.forEach { it.onMediaChanged(currentTrainingItem()) }
notifySentenceIfChanged(force = true)
notifySnapshot()
@@ -124,9 +179,20 @@ class OralTrainerController internal constructor(
mediaItems.clear()
mediaItems.addAll(items)
lastSentenceIndex = null
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs))
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
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()
@@ -136,12 +202,46 @@ class OralTrainerController internal constructor(
loadItems(listOf(item))
}
/**
* Replaces sentences without preparing/reloading media, preserving playback
* position and state so a background boundary refresh can apply immediately.
*/
fun updateSentences(mediaId: String, sentences: List<SentenceBoundary>): Boolean {
val index = mediaItems.indexOfFirst { it.id == mediaId }
if (index < 0) {
return false
}
val updatedItem = mediaItems[index].copy(sentences = sentences)
if (mediaItems[index] == updatedItem) {
return true
}
mediaItems[index] = updatedItem
if (index == baseItemIndex) {
listeners.forEach { it.onMediaChanged(updatedItem) }
lockSentenceAt(playbackPositionMs())
syncPlaybackWindow()
notifySentenceIfChanged(force = true)
notifySnapshot()
}
return true
}
fun play() {
syncPlaybackWindow()
if (clippedSentence != null && !player.isPlaying && shouldWarmClipStart()) {
playClippedSentenceFromStart()
return
}
player.play()
}
fun pause() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
player.pause()
applySentenceRepeatMode()
}
fun togglePlayPause() {
@@ -153,15 +253,28 @@ class OralTrainerController internal constructor(
}
fun stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
cancelClipStartWarmup(restoreVolume = true)
cancelSentenceEndGuard()
player.stop()
}
fun seekTo(positionMs: Long) {
player.seekTo(max(0L, positionMs))
stopAtSentenceEnd = false
stopAtSentenceEndIndex = null
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) {
@@ -173,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) {
@@ -191,28 +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
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 = 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 {
@@ -220,11 +376,16 @@ class OralTrainerController internal constructor(
if (item.sentences.isEmpty()) {
return false
}
val position = player.currentPosition
val target = item.sentences
.asReversed()
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
?: item.sentences.first()
val target = if (shouldBoundCurrentSentence()) {
val index = anchoredSentenceIndex(item) ?: 0
item.sentences.getOrNull((index - 1).coerceAtLeast(0)) ?: item.sentences.first()
} else {
val position = playbackPositionMs()
item.sentences
.asReversed()
.firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS }
?: item.sentences.first()
}
seekTo(target.startMs)
return true
}
@@ -234,10 +395,15 @@ class OralTrainerController internal constructor(
if (item.sentences.isEmpty()) {
return false
}
val position = player.currentPosition
val target = item.sentences.firstOrNull {
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
} ?: return false
val target = if (shouldBoundCurrentSentence()) {
val index = anchoredSentenceIndex(item) ?: return false
item.sentences.getOrNull(index + 1) ?: return false
} else {
val position = playbackPositionMs()
item.sentences.firstOrNull {
it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS
} ?: return false
}
seekTo(target.startMs)
return true
}
@@ -294,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,
@@ -312,6 +482,8 @@ class OralTrainerController internal constructor(
return
}
released = true
cancelClipStartWarmup(restoreVolume = false)
cancelSentenceEndGuard()
mainHandler.removeCallbacks(ticker)
listeners.clear()
player.release()
@@ -335,12 +507,291 @@ class OralTrainerController internal constructor(
listeners.forEach { it.onSentenceChanged(sentence) }
}
private fun TrainingMediaItem.toMedia3Item(): MediaItem {
private fun syncPlaybackWindow(positionMs: Long = playbackPositionMs()) {
if (released || updatingPlaybackWindow) {
return
}
val item = currentTrainingItem()
val target = sentenceForWindow(item)
if (target == null || item == null) {
restoreUnclipped(positionMs)
return
}
val current = clippedSentence
if (current != null &&
current.index == target.index &&
current.startMs == target.startMs &&
current.endMs == target.endMs
) {
applySentenceRepeatMode()
return
}
val relativeMs = (positionMs - target.startMs).coerceAtLeast(0L)
installSentenceClip(item, target, relativeMs)
}
private fun sentenceForWindow(item: TrainingMediaItem?): SentenceBoundary? {
if (item == null || !config.sentenceMode || item.sentences.isEmpty()) {
return null
}
return when {
stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null)
config.continuousPlayback -> null
else -> {
val index = lastSentenceIndex ?: sentenceAt(item, playbackPositionMs())?.index
item.sentences.getOrNull(index ?: return null)
}
}
}
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(
@@ -369,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)
}
@@ -28,6 +30,7 @@ class OralTrainerPlayerView @JvmOverloads constructor(
private var longPressStartY = 0f
private var longPressActive = false
private var speedBeforeLongPress: Float? = null
private var verticalSwipeListener: ((isSwipeUp: Boolean) -> Unit)? = null
private val longPressSpeedRunnable = Runnable {
val activeController = controller ?: return@Runnable
@@ -70,6 +73,11 @@ class OralTrainerPlayerView @JvmOverloads constructor(
val dy = e2.y - start.y
val minDistancePx = gestureControls.minSwipeDistanceDp * density
val minVelocityPx = gestureControls.minSwipeVelocityDpPerSecond * density
if (abs(dy) > minDistancePx && abs(dy) > abs(dx) && abs(velocityY) >= minVelocityPx) {
val isSwipeUp = dy < 0
verticalSwipeListener?.invoke(isSwipeUp)
return true
}
if (abs(dx) < abs(dy) || abs(dx) < minDistancePx || abs(velocityX) < minVelocityPx) {
return false
}
@@ -112,6 +120,30 @@ class OralTrainerPlayerView @JvmOverloads constructor(
gestureControls = config
}
fun setVerticalSwipeListener(listener: ((isSwipeUp: Boolean) -> Unit)?) {
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

@@ -11,6 +11,7 @@ class OralTrainerSdk private constructor(
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
val userApi: UserApi = UserApi(appContext, config)
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()

View File

@@ -8,6 +8,8 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
val userAgent: String = "OralTrainerSdk/0.1.0",
val connectTimeoutMs: Int = 15_000,
val readTimeoutMs: Int = 30_000,
val sentenceBoundaryApiBaseUrl: String = "https://video_service.d1kt.cn",
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
val assessmentApiKey: String? = null,
var userToken: String? = null,
val allowHttpFallback: Boolean = false,
)

View File

@@ -4,6 +4,7 @@ data class PlayerConfig @JvmOverloads constructor(
val sentenceMode: Boolean = true,
val defaultSeekStepMs: Long = 10_000L,
val autoPlay: Boolean = false,
val continuousPlayback: Boolean = true,
val minPlaybackSpeed: Float = 0.5f,
val maxPlaybackSpeed: Float = 2.0f,
val gestureControls: GestureControlsConfig = GestureControlsConfig(),

View File

@@ -65,43 +65,48 @@ class RemoteImitationQualityAssessor internal constructor(
require(SHA256_PATTERN.matches(videoHash)) {
"ImitationAssessmentRequest.mediaId or metadata[video_hash] must be a SHA-256 hash."
}
val endpoint = baseUri.buildUpon()
.appendPath("api")
.appendPath("v1")
.appendPath("videos")
.appendPath(videoHash.lowercase())
.appendPath("sentences")
.appendPath(request.sentence.index.toString())
.appendPath("assessments")
.build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
val boundary = "----OralTrainer-${UUID.randomUUID()}"
return try {
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = maxOf(config.readTimeoutMs, 180_000)
connection.setChunkedStreamingMode(64 * 1024)
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("X-Client-Key", it)
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
.appendPath("api")
.appendPath("v1")
.appendPath("videos")
.appendPath(videoHash.lowercase())
.appendPath("sentences")
.appendPath(request.sentence.index.toString())
.appendPath("assessments")
.build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
val boundary = "----OralTrainer-${UUID.randomUUID()}"
try {
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = maxOf(config.readTimeoutMs, 180_000)
connection.setChunkedStreamingMode(64 * 1024)
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("X-Client-Key", it)
}
config.userToken?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
BufferedOutputStream(connection.outputStream).use { output ->
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
writeFilePart(output, boundary, request.recordingUri)
output.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw RemoteAssessmentException(statusCode, body)
}
parseResponse(body)
} finally {
connection.disconnect()
}
BufferedOutputStream(connection.outputStream).use { output ->
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
writeFilePart(output, boundary, request.recordingUri)
output.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw RemoteAssessmentException(statusCode, body)
}
parseResponse(body)
} finally {
connection.disconnect()
}
}

View File

@@ -98,33 +98,39 @@ class SentenceBoundaryApi internal constructor(
}
private fun fetchBlocking(videoHash: String): SentenceBoundaryApiResult {
val endpoint = baseUri.buildUpon()
.appendPath("api")
.appendPath("v1")
.appendPath("videos")
.appendPath(videoHash)
.appendPath("sentence-boundaries")
.build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
return try {
connection.requestMethod = "GET"
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
.appendPath("api")
.appendPath("v1")
.appendPath("videos")
.appendPath(videoHash)
.appendPath("sentence-boundaries")
.build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
try {
connection.requestMethod = "GET"
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
config.userToken?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream
}
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw SentenceBoundaryApiException(statusCode, body)
}
parseResponse(body, videoHash)
} finally {
connection.disconnect()
}
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw SentenceBoundaryApiException(statusCode, body)
}
parseResponse(body, videoHash)
} finally {
connection.disconnect()
}
}
@@ -153,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

@@ -0,0 +1,392 @@
package cn.learningpad.oraltrainer.sdk
import android.content.Context
import android.os.Handler
import android.os.Looper
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
data class AuthUser(
val id: String,
val username: String,
val nickname: String,
)
data class AuthSession(
val token: String,
val user: AuthUser,
)
data class Course(
val videoHash: String,
val title: String,
val durationMs: Long?,
val language: String?,
val sentenceCount: Int,
val streamUrl: String,
val enrolled: Boolean,
) {
fun toVideoSummary(): TrainingVideoSummary {
return TrainingVideoSummary(
videoHash = videoHash,
title = title,
streamUri = android.net.Uri.parse(streamUrl),
durationMs = durationMs,
sizeBytes = 0L,
language = language,
sentenceCount = sentenceCount,
status = "ready",
)
}
}
data class UserResult(
val attemptId: String,
val videoHash: String,
val courseTitle: String,
val sentenceIndex: Int,
val sentenceText: String,
val overallScore: Float,
val passed: Boolean,
val createdAt: String,
)
data class UserDubShare(
val shareId: String,
val videoHash: String,
val courseTitle: String,
val title: String,
val segmentCount: Int,
val averageScore: Float,
val createdAt: String,
)
data class DubShareSegmentDetail(
val index: Int,
val overallScore: Float,
val recognizedText: String,
val scoreDetails: Map<String, Float>,
val audioUrl: String,
)
data class DubShareDetail(
val shareId: String,
val videoHash: String,
val title: String,
val segments: List<DubShareSegmentDetail>,
)
class UserApiException(
val statusCode: Int,
responseBody: String,
) : IOException("HTTP $statusCode: ${responseBody.take(300)}")
class UserApi internal constructor(
context: Context,
private val config: OralTrainerSdkConfig,
) {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val executor: ExecutorService = Executors.newCachedThreadPool()
private val baseUri = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
init {
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
"sentenceBoundaryApiBaseUrl must use http or https."
}
}
fun register(
username: String,
password: String,
nickname: String? = null,
phone: String,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) {
requestRegisterBlocking(
"api/v1/auth/register",
username = username,
password = password,
nickname = nickname,
phone = phone,
)
}
fun login(
username: String,
password: String,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) }
fun ssoExchange(
accessToken: String,
onComplete: (Result<SsoExchangeResult>) -> Unit,
): CancellableRequest = submit(onComplete) {
val root = JSONObject().put("access_token", accessToken)
val response = request("api/v1/auth/sso/exchange", body = root.toString(), method = "POST")
parseSsoExchangeResult(JSONObject(response))
}
fun ssoBindPhone(
token: String,
phone: String,
onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) {
val root = JSONObject().put("phone", phone)
request("api/v1/auth/sso/bind-phone", token = token, body = root.toString(), method = "POST")
}
fun courses(
token: String,
onComplete: (Result<List<Course>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "api/v1/courses")) }
fun enroll(
token: String,
videoHash: String,
onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) {
request("api/v1/courses/$videoHash/enroll", token, method = "POST")
}
fun results(
token: String,
onComplete: (Result<List<UserResult>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "api/v1/me/results")) }
fun dubShares(
token: String,
onComplete: (Result<List<UserDubShare>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) }
fun deleteResult(
token: String,
attemptId: String,
onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) {
request("api/v1/me/results/$attemptId", token, method = "DELETE")
}
fun deleteDubShare(
token: String,
shareId: String,
onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) {
request("api/v1/me/dub-shares/$shareId", token, method = "DELETE")
}
fun dubShareDetail(
shareId: String,
onComplete: (Result<DubShareDetail>) -> Unit,
): CancellableRequest = submit(onComplete) {
parseDubShareDetail(JSONObject(request("api/v1/dub-shares/$shareId", method = "GET")))
}
fun release() {
executor.shutdownNow()
}
private fun <T> submit(
onComplete: (Result<T>) -> Unit,
operation: () -> T,
): CancellableRequest {
val future: Future<*> = executor.submit {
post(onComplete, runCatching(operation))
}
return CancellableRequest(future)
}
private fun <T> submitObject(
onComplete: (Result<T>) -> Unit,
token: String,
operation: (String) -> T,
): CancellableRequest = submit(onComplete) { operation(token) }
private fun submitUnit(
onComplete: (Result<Unit>) -> Unit,
token: String,
operation: (String) -> Unit,
): CancellableRequest = submit(onComplete) {
operation(token)
Unit
}
private fun <T> post(onComplete: (Result<T>) -> Unit, result: Result<T>) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { onComplete(result) }
}
}
private fun requestBlocking(path: String, username: String, password: String, auth: Boolean): AuthSession {
val root = JSONObject().put("username", username).put("password", password)
val response = request(path, body = root.toString(), method = "POST")
return parseSession(JSONObject(response))
}
private fun requestRegisterBlocking(
path: String,
username: String,
password: String,
nickname: String?,
phone: String,
): AuthSession {
val cleanedPhone = phone.trim()
require(cleanedPhone.isNotEmpty()) { "请填写手机号" }
require(PHONE_PATTERN.matches(cleanedPhone)) { "请输入有效的 11 位手机号" }
val root = JSONObject()
.put("username", username)
.put("password", password)
nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) }
root.put("phone", cleanedPhone)
val response = request(path, body = root.toString(), method = "POST")
return parseSession(JSONObject(response))
}
private fun getBlocking(token: String, path: String): JSONArray {
val body = request(path, token = token, method = "GET")
return JSONArray(body)
}
private fun parseCourses(items: JSONArray): List<Course> = List(items.length()) { index ->
val item = items.getJSONObject(index)
Course(
videoHash = item.getString("video_hash"),
title = item.getString("title"),
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
language = if (item.isNull("language")) null else item.getString("language"),
sentenceCount = item.getInt("sentence_count"),
streamUrl = item.getString("stream_url"),
enrolled = item.getBoolean("enrolled"),
)
}
private fun parseResults(items: JSONArray): List<UserResult> = List(items.length()) { index ->
val item = items.getJSONObject(index)
UserResult(
attemptId = item.getString("attempt_id"),
videoHash = item.getString("video_hash"),
courseTitle = item.getString("course_title"),
sentenceIndex = item.getInt("sentence_index"),
sentenceText = item.getString("sentence_text"),
overallScore = item.getDouble("overall_score").toFloat(),
passed = item.getBoolean("passed"),
createdAt = item.getString("created_at"),
)
}
private fun parseDubShares(items: JSONArray): List<UserDubShare> = List(items.length()) { index ->
val item = items.getJSONObject(index)
UserDubShare(
shareId = item.getString("share_id"),
videoHash = item.getString("video_hash"),
courseTitle = item.getString("course_title"),
title = item.getString("title"),
segmentCount = item.getInt("segment_count"),
averageScore = item.getDouble("average_score").toFloat(),
createdAt = item.getString("created_at"),
)
}
private fun parseDubShareDetail(root: JSONObject): DubShareDetail {
val segmentsJson = root.optJSONArray("segments") ?: JSONArray()
val segments = List(segmentsJson.length()) { index ->
val item = segmentsJson.getJSONObject(index)
val detailsJson = item.optJSONObject("score_details") ?: JSONObject()
val scoreDetails = buildMap<String, Float> {
for (key in detailsJson.keys()) {
if (!detailsJson.isNull(key)) {
put(key, detailsJson.optDouble(key).toFloat())
}
}
}
DubShareSegmentDetail(
index = item.getInt("index"),
overallScore = item.optDouble("overall_score", 0.0).toFloat(),
recognizedText = item.optString("recognized_text"),
scoreDetails = scoreDetails,
audioUrl = item.getString("audio_url"),
)
}
return DubShareDetail(
shareId = root.getString("share_id"),
videoHash = root.getString("video_hash"),
title = root.optString("title"),
segments = segments,
)
}
private fun request(
path: String,
token: String? = null,
body: String? = null,
method: String = body?.let { "POST" } ?: "GET",
): String {
val endpoint = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
.buildUpon().appendEncodedPath(path).build()
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
try {
connection.requestMethod = method
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
token?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
if (body != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) throw UserApiException(statusCode, text)
return text
} finally {
connection.disconnect()
}
}
private fun parseSession(root: JSONObject): AuthSession {
val user = root.getJSONObject("user")
return AuthSession(
token = root.getString("token"),
user = AuthUser(
id = user.getString("id"),
username = user.getString("username"),
nickname = user.getString("nickname"),
),
)
}
private fun parseSsoExchangeResult(root: JSONObject): SsoExchangeResult {
val user = root.getJSONObject("user")
return SsoExchangeResult(
token = root.getString("token"),
user = AuthUser(
id = user.getString("id"),
username = user.getString("username"),
nickname = user.getString("nickname"),
),
phoneBound = root.optBoolean("phone_bound", false),
isNewUser = root.optBoolean("is_new_user", false),
)
}
}
data class SsoExchangeResult(
val token: String,
val user: AuthUser,
val phoneBound: Boolean,
val isNewUser: Boolean,
)
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")

View File

@@ -44,7 +44,6 @@ class VideoCatalogApi internal constructor(
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val executor: ExecutorService = Executors.newCachedThreadPool()
private val baseUrl = config.sentenceBoundaryApiBaseUrl.trimEnd('/') + "/"
fun fetch(callback: VideoCatalogCallback): CancellableRequest {
val future = executor.submit {
@@ -67,37 +66,45 @@ class VideoCatalogApi internal constructor(
}
private fun fetchBlocking(): List<TrainingVideoSummary> {
val endpoint = URL(URL(baseUrl), "api/v1/videos")
val connection = endpoint.openConnection() as HttpURLConnection
return try {
connection.requestMethod = "GET"
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) throw VideoCatalogApiException(statusCode, body)
parseResponse(body)
} finally {
connection.disconnect()
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
val endpoint = URL(URL(effectiveBaseUrl), "api/v1/videos")
val connection = endpoint.openConnection() as HttpURLConnection
try {
connection.requestMethod = "GET"
connection.connectTimeout = config.connectTimeoutMs
connection.readTimeout = config.readTimeoutMs
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) throw VideoCatalogApiException(statusCode, body)
parseResponse(body, effectiveBaseUrl)
} finally {
connection.disconnect()
}
}
}
private fun parseResponse(body: String): List<TrainingVideoSummary> {
private fun parseResponse(body: String, effectiveBaseUrl: String): List<TrainingVideoSummary> {
val videos = JSONObject(body).getJSONArray("videos")
val effectiveScheme = Uri.parse(effectiveBaseUrl).scheme
return buildList(videos.length()) {
for (index in 0 until videos.length()) {
val item = videos.getJSONObject(index)
val status = item.getString("status")
if (status != "ready") continue
val streamUrl = URL(URL(baseUrl), item.getString("stream_url"))
val resolvedStreamUrl = URL(URL(effectiveBaseUrl), item.getString("stream_url")).toString()
val streamUri = if (effectiveScheme == "http") {
Uri.parse(resolvedStreamUrl).buildUpon().scheme("http").build().toString()
} else {
resolvedStreamUrl
}
add(
TrainingVideoSummary(
videoHash = item.getString("video_hash"),
title = item.getString("title"),
streamUri = Uri.parse(streamUrl.toString()),
streamUri = Uri.parse(streamUri),
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
sizeBytes = item.getLong("size_bytes"),
language = if (item.isNull("language")) null else item.getString("language"),

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionName = "0.1.6"
}
compileOptions {
@@ -36,5 +36,8 @@ 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,9 +1,15 @@
<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"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
@@ -15,5 +21,20 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TrainingPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

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

@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:strokeColor="@android:color/white"
android:strokeWidth="3.2"
android:pathData="M15,15h10v20h-10z"/>
<path
android:strokeColor="@android:color/white"
android:strokeWidth="3.2"
android:strokeLineCap="round"
android:pathData="M25,11a12,12 0 0 1 11,7.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M40.8,14.6L37.2,20.4L34.1,13.8Z"/>
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_bg" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +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">口语宝</string>
<string name="app_name">MynaTalk</string>
<string name="training_playback_channel_name">Training playback</string>
<string name="training_playback_notification_title">Oral training</string>
<string name="training_playback_notification_text">Playing in the background</string>
</resources>

View File

@@ -2,7 +2,8 @@
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#2563EB</item>
</style>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="dubbing"
path="." />
</paths>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
允许 HTTP 明文访问,便于直连内网/开发环境部署的服务器(如 http://192.168.x.x)。
同时信任系统证书和用户手动安装的证书(自签/私有 CA 时需先把证书装到手机)。
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,257 @@
# 微信分享返回提示显示“八哥口语”改造备忘
## 背景
当前 Android 配音分享使用系统 `Intent.ACTION_SEND`,入口位于:
```kotlin
android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt
```
`shareMergedDubbing()` 会构造一个 `ACTION_SEND` Intent,然后通过 `startActivity(Intent.createChooser(...))` 调起微信或其他应用。
这种方式下,微信只能识别到“第三方应用通过系统分享调起”,无法可靠关联到微信开放平台登记的 App 信息,因此完成后的返回提示通常显示为“留在微信 / 返回第三方工具”。
要让提示变成“返回八哥口语”,需要改为微信官方 OpenSDK 分享,并在微信开放平台完成应用登记。
## 目标
1. 配音链接通过微信 OpenSDK 分享为网页卡片。
2. 微信完成页返回提示能识别并显示“八哥口语”。
3. 保留现有上传分享链接的逻辑,只替换最终调起微信的方式。
## 需要提前准备的信息
| 项目 | 当前值 |
|---|---|
| 应用名称 | 八哥口语 |
| Android 包名 | `cn.learningpad.oraltrainer.sample` |
| Gradle namespace | `cn.learningpad.oraltrainer.sample` |
| 微信回调 Activity 包名 | `cn.learningpad.oraltrainer.sample.wxapi` |
| 回调 Activity 类名 | `WXEntryActivity` |
| 本地名称资源 | `android:label="@string/app_name"` |
还需要从微信开放平台获取:
- 微信 AppID
- 应用签名 MD5
- 已审核通过的移动应用信息
## 一、微信开放平台登记
1. 登录 [微信开放平台](https://open.weixin.qq.com/)。
2. 进入“管理中心”,创建或打开移动应用。
3. 填写应用名称为“八哥口语”。
4. 配置 Android 应用信息:
- 包名:`cn.learningpad.oraltrainer.sample`
- 签名:使用正式签名 APK 的应用签名 MD5
5. 提交审核,等待微信开放平台通过。
6. 审核通过后记录微信 AppID。
注意:微信校验的是正式签名。Debug 构建如果也要联调,需要在开放平台补充 Debug 签名,或临时使用与正式签名一致的构建配置。
## 二、获取正式签名 MD5
安装已签名的 APK 后执行:
```bash
adb shell dumpsys package cn.learningpad.oraltrainer.sample | grep -A 2 "Signatures"
```
更稳妥的方式是使用微信官方提供的生成签名工具,或在本地用正式 keystore 查询证书指纹:
```bash
keytool -list -v -alias <key-alias> -keystore <keystore-file>
```
将结果中的 `MD5` 去掉冒号并转为小写后填入微信开放平台。
## 三、添加微信 OpenSDK
修改文件:
```text
android/sample-app/build.gradle.kts
```
在 `dependencies` 中加入:
```kotlin
implementation("com.tencent.mm.opensdk:wechat-sdk-android:6.7.0")
```
建议实施时检查 Maven 上是否有更新稳定版;如果没有特殊需求,可继续固定当前版本,避免 SDK 行为漂移。
## 四、初始化微信 API
建议在 `MainActivity` 中增加:
```kotlin
private lateinit var wxApi: IWXAPI
```
在创建 Activity 或进入配音功能前初始化:
```kotlin
wxApi = WXAPIFactory.createWXAPI(this, WECHAT_APP_ID, true)
wxApi.registerApp(WECHAT_APP_ID)
```
`WECHAT_APP_ID` 应放入构建配置、Manifest metadata 或私有配置中,不要硬编码在业务代码里。
## 五、替换系统分享调用
保留现有 `uploadDubShare()`,拿到 `shareUrl` 后改用:
```kotlin
val webpage = WXWebpageObject().apply {
webpageUrl = shareUrl
}
val message = WXMediaMessage(webpage).apply {
title = "我的口语配音"
description = item.title
}
val request = SendMessageToWX.Req().apply {
message = message
transaction = "dub_share"
scene = SendMessageToWX.Req.WXSceneSession
}
if (!wxApi.sendReq(request)) {
setDubbingStatus("分享失败:未检测到可用的微信客户端")
} else {
setDubbingStatus("已生成分享链接:$shareUrl")
}
```
如需支持朋友圈,把 `scene` 改为:
```kotlin
SendMessageToWX.Req.WXSceneTimeline
```
当前需求主要是发给好友或群聊,建议先保持 `WXSceneSession`。
## 六、新增微信回调 Activity
必须严格按包名规则创建:
```text
android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/wxapi/WXEntryActivity.kt
```
示例内容:
```kotlin
package cn.learningpad.oraltrainer.sample.wxapi
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import com.tencent.mm.opensdk.modelbase.BaseReq
import com.tencent.mm.opensdk.modelbase.BaseResp
import com.tencent.mm.opensdk.openapi.IWXAPI
import com.tencent.mm.opensdk.openapi.IWXAPIEventHandler
import com.tencent.mm.opensdk.openapi.WXAPIFactory
class WXEntryActivity : Activity(), IWXAPIEventHandler {
private lateinit var api: IWXAPI
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val info = packageManager.getApplicationInfo(
packageName,
PackageManager.GET_META_DATA
)
val appId = info.metaData.getString("com.tencent.wechat.APP_ID").orEmpty()
api = WXAPIFactory.createWXAPI(this, appId, true)
api.handleIntent(intent, this)
finish()
}
override fun onReq(req: BaseReq) {}
override fun onResp(resp: BaseResp) {
finish()
}
}
```
## 七、注册回调 Activity
修改:
```text
android/sample-app/src/main/AndroidManifest.xml
```
在 `<application>` 内添加:
```xml
<activity
android:name=".wxapi.WXEntryActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTop"
android:taskAffinity="cn.learningpad.oraltrainer.sample"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
```
`android:label="@string/app_name"` 对应中文资源里的“八哥口语”:
```text
android/sample-app/src/main/res/values-zh/strings.xml
```
## 八、配置 AppID
推荐方式是在 Manifest 中加:
```xml
<meta-data
android:name="com.tencent.wechat.APP_ID"
android:value="${wechatAppId}" />
```
并在 `build.gradle.kts` 中加:
```kotlin
manifestPlaceholders["wechatAppId"] = "替换为微信AppID"
```
正式项目建议不要直接提交真实密钥类配置。虽然微信 AppID 本身不是私钥,但仍更适合放到本地配置或 CI 注入。
## 九、验收清单
- [ ] 正式签名包安装成功。
- [ ] 点击“分享配音”后不再走系统分享面板,而是直接拉起微信。
- [ ] 微信好友会话中出现网页卡片,标题为“我的口语配音”。
- [ ] 卡片点击后能正常打开配音分享页。
- [ ] 分享完成后,微信返回提示显示“八哥口语”相关文案,而不是“第三方工具”。
- [ ] 从微信返回后,八哥口语仍停留在原界面。
- [ ] 未安装微信时给出明确失败提示,不崩溃。
- [ ] Debug 与 Release 的包名和签名策略确认无误。
## 十、容易踩坑的点
1. **包名必须完全一致**
`WXEntryActivity` 必须放在 `cn.learningpad.oraltrainer.sample.wxapi` 下,否则微信无法回调。
2. **签名必须是开放平台登记的那个**
Debug 签名和 Release 签名不同时,OpenSDK 可能无法正常调起或回调。
3. **应用名称以微信开放平台为准**
只修改 Android 本地 `app_name` 不一定足够;微信返回文案依赖开放平台登记的应用信息。
4. **不要删除现有网页分享逻辑**
`uploadDubShare()` 仍然负责生成服务端分享链接,微信 OpenSDK 只负责把这个链接作为网页对象发送。
5. **旧版本微信兼容性**
如果用户微信版本过低,`sendReq()` 可能返回 false。应给用户明确提示。
## 结论
这个问题的根因是当前实现使用系统 `ACTION_SEND`,而不是微信官方 OpenSDK。
要显示“返回八哥口语”,必须完成微信开放平台登记、正式签名绑定,并把分享路径切换为 `IWXAPI.sendReq()`。

View File

@@ -0,0 +1,487 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
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 */; };
AA0000000000000000000044 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000024 /* Config.swift */; };
AA0000000000000000000045 /* TrainingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000025 /* TrainingModels.swift */; };
AA0000000000000000000046 /* AssessmentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000026 /* AssessmentModels.swift */; };
AA0000000000000000000047 /* PlaybackModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000027 /* PlaybackModels.swift */; };
AA0000000000000000000048 /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000028 /* HTTPClient.swift */; };
AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000029 /* VideoCatalogAPI.swift */; };
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002A /* SentenceBoundaryAPI.swift */; };
AA000000000000000000004B /* ImitationAssessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002B /* ImitationAssessor.swift */; };
AA000000000000000000004C /* PlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002C /* PlayerController.swift */; };
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002D /* PlayerSurfaceView.swift */; };
AA000000000000000000004E /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002E /* Theme.swift */; };
AA000000000000000000004F /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000002F /* RootView.swift */; };
AA0000000000000000000050 /* TrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000030 /* TrainingView.swift */; };
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000031 /* PlayerSectionView.swift */; };
AA0000000000000000000052 /* CatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CatalogView.swift */; };
AA0000000000000000000053 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000033 /* TestView.swift */; };
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* FullscreenPlayerView.swift */; };
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; };
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A6 /* StreamingVideoCache.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = "<group>"; };
AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = "<group>"; };
AA00000000000000000000B1 /* SentenceExplanationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceExplanationStore.swift; sourceTree = "<group>"; };
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AA0000000000000000000022 /* OralTrainerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OralTrainerApp.swift; sourceTree = "<group>"; };
AA0000000000000000000023 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AA0000000000000000000024 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
AA0000000000000000000025 /* TrainingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingModels.swift; sourceTree = "<group>"; };
AA0000000000000000000026 /* AssessmentModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentModels.swift; sourceTree = "<group>"; };
AA0000000000000000000027 /* PlaybackModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackModels.swift; sourceTree = "<group>"; };
AA0000000000000000000028 /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
AA0000000000000000000029 /* VideoCatalogAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCatalogAPI.swift; sourceTree = "<group>"; };
AA000000000000000000002A /* SentenceBoundaryAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryAPI.swift; sourceTree = "<group>"; };
AA000000000000000000002B /* ImitationAssessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImitationAssessor.swift; sourceTree = "<group>"; };
AA000000000000000000002C /* PlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerController.swift; sourceTree = "<group>"; };
AA000000000000000000002D /* PlayerSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSurfaceView.swift; sourceTree = "<group>"; };
AA000000000000000000002E /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
AA000000000000000000002F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
AA0000000000000000000030 /* TrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingView.swift; sourceTree = "<group>"; };
AA0000000000000000000031 /* PlayerSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSectionView.swift; sourceTree = "<group>"; };
AA0000000000000000000032 /* CatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogView.swift; sourceTree = "<group>"; };
AA0000000000000000000033 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = "<group>"; };
AA0000000000000000000034 /* FullscreenPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenPlayerView.swift; sourceTree = "<group>"; };
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = "<group>"; };
AA00000000000000000000A6 /* StreamingVideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingVideoCache.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
AA000000000000000000000E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA0000000000000000000002 = {
isa = PBXGroup;
children = (
AA0000000000000000000004 /* OralTrainer */,
AA0000000000000000000003 /* Products */,
);
sourceTree = "<group>";
};
AA0000000000000000000003 /* Products */ = {
isa = PBXGroup;
children = (
AA000000000000000000000A /* OralTrainer.app */,
);
name = Products;
sourceTree = "<group>";
};
AA0000000000000000000004 /* OralTrainer */ = {
isa = PBXGroup;
children = (
AA0000000000000000000005 /* Models */,
AA0000000000000000000006 /* Services */,
AA0000000000000000000007 /* Player */,
AA0000000000000000000008 /* Views */,
AA0000000000000000000009 /* Resources */,
AA0000000000000000000022 /* OralTrainerApp.swift */,
AA0000000000000000000023 /* AppDelegate.swift */,
AA0000000000000000000024 /* Config.swift */,
);
path = OralTrainer;
sourceTree = "<group>";
};
AA0000000000000000000005 /* Models */ = {
isa = PBXGroup;
children = (
AA0000000000000000000025 /* TrainingModels.swift */,
AA0000000000000000000026 /* AssessmentModels.swift */,
AA0000000000000000000027 /* PlaybackModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
AA0000000000000000000006 /* Services */ = {
isa = PBXGroup;
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>";
};
AA0000000000000000000007 /* Player */ = {
isa = PBXGroup;
children = (
AA000000000000000000002C /* PlayerController.swift */,
AA000000000000000000002D /* PlayerSurfaceView.swift */,
);
path = Player;
sourceTree = "<group>";
};
AA0000000000000000000008 /* Views */ = {
isa = PBXGroup;
children = (
AA000000000000000000002E /* Theme.swift */,
AA000000000000000000002F /* RootView.swift */,
AA0000000000000000000093 /* AuthView.swift */,
AA0000000000000000000030 /* TrainingView.swift */,
AA0000000000000000000031 /* PlayerSectionView.swift */,
AA0000000000000000000032 /* CatalogView.swift */,
AA0000000000000000000033 /* TestView.swift */,
AA0000000000000000000034 /* FullscreenPlayerView.swift */,
);
path = Views;
sourceTree = "<group>";
};
AA0000000000000000000009 /* Resources */ = {
isa = PBXGroup;
children = (
AA0000000000000000000020 /* Assets.xcassets */,
AA000000000000000000001F /* Info.plist */,
AA0000000000000000000070 /* InfoPlist.strings */,
);
path = Resources;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA000000000000000000000B /* OralTrainer */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "OralTrainer" */;
buildPhases = (
AA000000000000000000000C /* Sources */,
AA000000000000000000000E /* Frameworks */,
AA000000000000000000000D /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = OralTrainer;
productName = OralTrainer;
productReference = AA000000000000000000000A /* OralTrainer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA0000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 2660;
TargetAttributes = {
AA000000000000000000000B = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "OralTrainer" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = AA0000000000000000000002;
productRefGroup = AA0000000000000000000003 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AA000000000000000000000B /* OralTrainer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA000000000000000000000D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000000000000000000040 /* Assets.xcassets in Resources */,
AA0000000000000000000060 /* InfoPlist.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA000000000000000000000C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */,
AA0000000000000000000043 /* AppDelegate.swift in Sources */,
AA0000000000000000000044 /* Config.swift in Sources */,
AA0000000000000000000045 /* TrainingModels.swift in Sources */,
AA0000000000000000000046 /* AssessmentModels.swift in Sources */,
AA0000000000000000000047 /* PlaybackModels.swift in Sources */,
AA0000000000000000000048 /* HTTPClient.swift in Sources */,
AA0000000000000000000049 /* VideoCatalogAPI.swift in Sources */,
AA000000000000000000004A /* SentenceBoundaryAPI.swift in Sources */,
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */,
AA000000000000000000004C /* PlayerController.swift in Sources */,
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
AA000000000000000000004E /* Theme.swift in Sources */,
AA000000000000000000004F /* RootView.swift in Sources */,
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
AA0000000000000000000070 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
AA0000000000000000000071 /* zh-Hans */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 28FY92RBGB;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
AA0000000000000000000013 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 28FY92RBGB;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AA0000000000000000000014 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OralTrainer/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.learningpad.oraltrainer.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA0000000000000000000015 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OralTrainer/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.learningpad.oraltrainer.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA0000000000000000000010 /* Build configuration list for PBXProject "OralTrainer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000012 /* Debug */,
AA0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "OralTrainer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000014 /* Debug */,
AA0000000000000000000015 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000001 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

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">
<dict/>
</plist>

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

@@ -0,0 +1,16 @@
<?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">
<dict>
<key>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CompilationCachingSetting</key>
<string>Default</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?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">
<dict>
<key>SchemeUserState</key>
<dict>
<key>OralTrainer.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,46 @@
import UIKit
final class AppDelegate: NSObject, UIApplicationDelegate {
static var orientationLock: UIInterfaceOrientationMask = .portrait
func application(
_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?
) -> 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

@@ -0,0 +1,29 @@
import Foundation
enum AppConfig {
/// 服务端地址:改成你实际部署的域名或 IP。
/// 内网/开发环境可填 http://<服务器IP>:<端口>(例如 http://192.168.1.100:80)。
static let serverBaseURL = "https://videoservice.d1kt.cn"
/// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
static let assessmentAPIKey = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
/// 与安卓端一致:HTTPS 请求因 TLS 问题失败时自动降级重试 HTTP(仅限内网/私有部署)。
static let allowHTTPFallback = true
static let userAgent = "OralTrainerKit/0.1.0 (iOS)"
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

@@ -0,0 +1,98 @@
import Foundation
struct ImitationAssessmentRequest {
let mediaId: String
let sentence: SentenceBoundary
let recordingURL: URL
let locale: String?
}
struct TextSubstitution: Decodable {
let expected: String
let actual: String
}
struct ImitationAssessmentResult: Decodable {
let attemptId: String?
let scoringVersion: String?
let overallScore: Float
let passed: Bool?
let passScore: Float?
let contentScore: Float?
let completenessScore: Float?
let fluencyScore: Float?
let pronunciationScore: Float?
let prosodyScore: Float?
let durationScore: Float?
let pauseScore: Float?
let speechRateScore: Float?
let referenceText: String?
let recognizedText: String?
let referenceDurationMs: Int64?
let referenceSpeechDurationMs: Int64?
let studentRecordingDurationMs: Int64?
let studentSpeechDurationMs: Int64?
let durationRatio: Float?
let missingTokens: [String]
let extraTokens: [String]
let substitutions: [TextSubstitution]
let feedback: String?
let details: [String: String]
private enum CodingKeys: String, CodingKey {
case attemptId = "attempt_id"
case scoringVersion = "scoring_version"
case overallScore = "overall_score"
case passed
case passScore = "pass_score"
case contentScore = "content_score"
case completenessScore = "completeness_score"
case fluencyScore = "fluency_score"
case pronunciationScore = "pronunciation_score"
case prosodyScore = "prosody_score"
case durationScore = "duration_score"
case pauseScore = "pause_score"
case speechRateScore = "speech_rate_score"
case referenceText = "reference_text"
case recognizedText = "recognized_text"
case referenceDurationMs = "reference_duration_ms"
case referenceSpeechDurationMs = "reference_speech_duration_ms"
case studentRecordingDurationMs = "student_recording_duration_ms"
case studentSpeechDurationMs = "student_speech_duration_ms"
case durationRatio = "duration_ratio"
case missingTokens = "missing_tokens"
case extraTokens = "extra_tokens"
case substitutions
case feedback
case details
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
attemptId = try container.decodeIfPresent(String.self, forKey: .attemptId)
scoringVersion = try container.decodeIfPresent(String.self, forKey: .scoringVersion)
overallScore = try container.decodeIfPresent(Float.self, forKey: .overallScore) ?? 0
passed = try container.decodeIfPresent(Bool.self, forKey: .passed)
passScore = try container.decodeIfPresent(Float.self, forKey: .passScore)
contentScore = try container.decodeIfPresent(Float.self, forKey: .contentScore)
completenessScore = try container.decodeIfPresent(Float.self, forKey: .completenessScore)
fluencyScore = try container.decodeIfPresent(Float.self, forKey: .fluencyScore)
pronunciationScore = try container.decodeIfPresent(Float.self, forKey: .pronunciationScore)
prosodyScore = try container.decodeIfPresent(Float.self, forKey: .prosodyScore)
durationScore = try container.decodeIfPresent(Float.self, forKey: .durationScore)
pauseScore = try container.decodeIfPresent(Float.self, forKey: .pauseScore)
speechRateScore = try container.decodeIfPresent(Float.self, forKey: .speechRateScore)
referenceText = try container.decodeIfPresent(String.self, forKey: .referenceText)
recognizedText = try container.decodeIfPresent(String.self, forKey: .recognizedText)
referenceDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceDurationMs)
referenceSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceSpeechDurationMs)
studentRecordingDurationMs = try container.decodeIfPresent(Int64.self, forKey: .studentRecordingDurationMs)
studentSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .studentSpeechDurationMs)
durationRatio = try container.decodeIfPresent(Float.self, forKey: .durationRatio)
missingTokens = try container.decodeIfPresent([String].self, forKey: .missingTokens) ?? []
extraTokens = try container.decodeIfPresent([String].self, forKey: .extraTokens) ?? []
substitutions = try container.decodeIfPresent([TextSubstitution].self, forKey: .substitutions) ?? []
feedback = try container.decodeIfPresent(String.self, forKey: .feedback)
details = try container.decodeIfPresent([String: String].self, forKey: .details) ?? [:]
}
}

View File

@@ -0,0 +1,84 @@
import Foundation
enum PlaybackState: String {
case idle
case buffering
case ready
case ended
}
struct PlaybackSnapshot: Equatable {
var mediaId: String?
var positionMs: Int64 = 0
var durationMs: Int64 = -1
var bufferedPositionMs: Int64 = 0
var isPlaying = false
var state: PlaybackState = .idle
var speed: Float = 1
var sentenceIndex: Int?
}
struct PlayerConfig {
var sentenceMode = true
var defaultSeekStepMs: Int64 = 10_000
var autoPlay = false
var continuousPlayback = true
var minPlaybackSpeed: Float = 0.5
var maxPlaybackSpeed: Float = 2.0
}
enum LoopMode {
case off
case one
case all
}
enum SwipeAction {
case none
case rewind
case forward
case previousSentence
case nextSentence
case previousSentenceOrRewind
case nextSentenceOrForward
}
enum GestureKind {
case singleTap
case swipeLeft
case swipeRight
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

@@ -0,0 +1,151 @@
import Foundation
struct SentenceBoundary: Identifiable, Equatable, Decodable {
let index: Int
let startMs: Int64
let endMs: Int64
let text: String?
let language: String?
let referenceSpeechDurationMs: Int64?
let zhText: String?
var id: Int { index }
private enum CodingKeys: String, CodingKey {
case index, text, language
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
)
}
}
struct SentenceBoundaryDocument: Decodable {
let videoHash: String
let durationMs: Int64
let algorithmVersion: String
let sentences: [SentenceBoundary]
private enum CodingKeys: String, CodingKey {
case videoHash = "video_hash"
case durationMs = "duration_ms"
case algorithmVersion = "algorithm_version"
case sentences
}
}
struct TrainingVideoSummary: Identifiable, Decodable {
let videoHash: String
let title: String
let streamUrl: String
let durationMs: Int64?
let sizeBytes: Int64
let language: String?
let sentenceCount: Int
let status: String
var id: String { videoHash }
private enum CodingKeys: String, CodingKey {
case title, language, status
case videoHash = "video_hash"
case streamUrl = "stream_url"
case durationMs = "duration_ms"
case sizeBytes = "size_bytes"
case sentenceCount = "sentence_count"
}
/// 与安卓端一致:相对路径基于服务地址解析;服务地址为 http 时强制使用 http 流地址。
func resolvedStreamURL(baseURL: String) -> URL? {
guard let base = URL(string: baseURL) else { return nil }
var absolute = URL(string: streamUrl, relativeTo: base)?.absoluteString ?? streamUrl
if base.scheme == "http" {
absolute = absolute.replacingOccurrences(
of: "https://",
with: "http://",
options: [.anchored]
)
}
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 {
let id: String
let title: String
let url: URL
let sentences: [SentenceBoundary]
let isLocalFile: Bool
init(
id: String,
title: String,
url: URL,
sentences: [SentenceBoundary] = [],
isLocalFile: Bool = false
) {
self.id = id
self.title = title
self.url = url
self.sentences = sentences
self.isLocalFile = isLocalFile
}
static func == (lhs: TrainingMediaItem, rhs: TrainingMediaItem) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -0,0 +1,12 @@
import SwiftUI
@main
struct OralTrainerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}

View File

@@ -0,0 +1,883 @@
import AVFoundation
import Combine
import Foundation
/// 与安卓端 OralTrainerController 对齐的播放控制器。
/// 所有属性变更都发生在主线程(时间观察器、KVO、UI 回调均为主线程)。
final class PlayerController: ObservableObject {
@Published private(set) var snapshot = PlaybackSnapshot()
@Published private(set) var currentItem: TrainingMediaItem?
@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()
var config = PlayerConfig()
var loopMode: LoopMode = .all
private var mediaItem: TrainingMediaItem?
private var lastSentenceIndex: Int?
private var pendingSeekPositionMs: Int64?
private var seekGeneration = 0
/// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在
/// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断
/// 用户是否希望继续播放。
private var playbackIntent = false
private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int?
private var swipeBackAnchorIndex: Int?
private var swipeBackAnchorTime: TimeInterval = 0
private var speedBeforeLongPress: Float?
private var transientTask: Task<Void, Never>?
private var cacheFallbackTask: Task<Void, Never>?
private var activePlaybackURL: URL?
private var didAttemptFullFileFallback = false
private var sentenceEndTimeObserver: Any?
private var sentenceEndGuardToken = 0
private static let swipeBackPhaseTimeout: TimeInterval = 1.2
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
/// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。
private static let sentenceSeekInsetMs: Int64 = 20
private static let previousSentenceToleranceMs: Int64 = 250
private static let nextSentenceToleranceMs: Int64 = 150
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var itemStatusObserver: NSKeyValueObservation?
private var timeControlObserver: NSKeyValueObservation?
init() {
configureAudioSession()
player.automaticallyWaitsToMinimizeStalling = true
player.actionAtItemEnd = .pause
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] _ in
self?.tick()
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleEnded()
}
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async {
self?.refreshSentenceBoundaryGuard()
self?.tick()
}
}
tick()
}
deinit {
if let timeObserver = timeObserver {
player.removeTimeObserver(timeObserver)
}
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
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()
}
func setVolume(_ volume: Float) {
player.volume = volume
}
func togglePlayPause() {
if player.timeControlStatus == .playing {
pause()
} else {
play()
}
}
func stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
cancelSentenceBoundaryGuard()
player.pause()
seek(to: .zero)
}
func seek(to positionMs: Int64) {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
performSeek(to: positionMs)
}
func seekRelative(_ deltaMs: Int64) {
seek(to: referencePositionMs() + deltaMs)
}
func rewind() {
seekRelative(-max(0, config.defaultSeekStepMs))
}
func fastForward() {
seekRelative(max(0, config.defaultSeekStepMs))
}
// MARK: - 按句跳转
@discardableResult
func seekToPreviousSentence() -> Bool {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
// 播放点落在两句之间的空隙时也能正确定位前一句。
let position = referencePositionMs()
let target = item.sentences
.reversed()
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
?? item.sentences[0]
seekToSentenceBeginning(target)
return true
}
@discardableResult
func seekToNextSentence() -> Bool {
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 + Self.nextSentenceToleranceMs
}) else {
return false
}
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()
}
}
func nextSentenceOrForward() {
if !config.sentenceMode || !seekToNextSentence() {
fastForward()
}
}
/// 播放中向左划:第一次回到当前句开头重播;短时间内再划一次才到上一句。
/// 暂停时保持原来的“上一句”语义,测试页“上一句”按钮也不受影响。
@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
}
stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentence.index
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) {
loopMode = mode
}
func setPlaybackSpeed(_ speed: Float) {
let clamped = min(config.maxPlaybackSpeed, max(config.minPlaybackSpeed, speed))
if player.timeControlStatus == .playing {
player.rate = clamped
} else {
player.defaultRate = clamped
}
tick()
}
// MARK: - 长按变速(与安卓端 GestureControlsConfig 一致)
func beginLongPressSpeed(atX x: CGFloat, width: CGFloat) {
speedBeforeLongPress = snapshot.speed
setPlaybackSpeed(speedForTouchX(x, width: width))
showTransientMessage("变速播放中")
}
func updateLongPressSpeed(atX x: CGFloat, width: CGFloat) {
setPlaybackSpeed(speedForTouchX(x, width: width))
}
func endLongPressSpeed() {
if let speed = speedBeforeLongPress {
setPlaybackSpeed(speed)
}
speedBeforeLongPress = nil
}
private func speedForTouchX(_ x: CGFloat, width: CGFloat) -> Float {
guard width > 0 else {
return 1
}
let center = width / 2
let distanceRatio = abs(x - center) / center
let farFromCenter = distanceRatio >= 0.5
if x < center {
return farFromCenter ? 0.5 : 0.75
}
return farFromCenter ? 2.0 : 1.5
}
// MARK: - 手势消息
func handleGesture(_ kind: GestureKind) {
switch kind {
case .singleTap:
togglePlayPause()
showTransientMessage("播放状态已切换")
case .swipeLeft:
switch replayOrPreviousSentence() {
case .restartedCurrent:
showTransientMessage("已回到本句开头,再左划返回上一句")
case .movedPrevious:
showTransientMessage("已跳到上一句")
case .stayedAtFirst:
showTransientMessage("已是第一句")
case .rewound:
showTransientMessage("已快退")
}
case .swipeRight:
nextSentenceOrForward()
showTransientMessage("已跳到下一句")
case .longPressSpeed:
break
}
}
func showTransientMessage(_ message: String) {
transientMessage = message
transientTask?.cancel()
transientTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else {
return
}
self?.transientMessage = nil
}
}
// MARK: - 状态刷新
private func tick() {
guard let item = player.currentItem else {
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
return
}
let position = referencePositionMs()
if pendingSeekPositionMs == nil, lastSentenceIndex == nil {
lastSentenceIndex = sentence(at: position)?.index
}
if pendingSeekPositionMs == nil {
enforceSentenceBoundary(positionMs: position)
}
let finalPosition = referencePositionMs()
let sentence = sentence(at: finalPosition)
if sentence?.index != lastSentenceIndex {
lastSentenceIndex = sentence?.index
}
if sentence?.index != swipeBackAnchorIndex {
swipeBackAnchorIndex = nil
}
let duration = durationMs(of: item)
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
snapshot = PlaybackSnapshot(
mediaId: mediaItem?.id,
positionMs: finalPosition,
durationMs: duration,
bufferedPositionMs: bufferedPositionMs(of: item),
isPlaying: player.timeControlStatus == .playing,
state: state,
speed: currentSpeed(),
sentenceIndex: sentence?.index
)
currentSentence = sentence
}
private func currentPositionMs() -> Int64 {
let seconds = player.currentTime().seconds
guard seconds.isFinite else {
return 0
}
return max(0, Int64(seconds * 1000))
}
/// 连续手势/快进快退时使用 pending seek 的目标位置,避免仍基于旧的播放位置计算。
private func referencePositionMs() -> Int64 {
pendingSeekPositionMs ?? currentPositionMs()
}
private func preparePlaybackItem(with playbackURL: URL) {
let newItem = makePlayerItem(url: playbackURL)
activePlaybackURL = playbackURL
attachItemObservers(to: newItem)
player.replaceCurrentItem(with: newItem)
let shouldPlay = config.autoPlay || playbackIntent
if shouldPlay {
player.play()
}
playbackIntent = shouldPlay
tick()
}
private func makePlayerItem(url: URL) -> AVPlayerItem {
guard !url.isFileURL else {
return AVPlayerItem(url: url)
}
var options: [String: Any] = [:]
let ext = url.pathExtension.lowercased()
if ext.isEmpty || ["mp4", "m4v", "mov"].contains(ext) {
options["AVURLAssetOverrideMIMETypeKey"] = "video/mp4"
}
let asset = AVURLAsset(url: url, options: options.isEmpty ? nil : options)
return AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: ["playable", "tracks", "duration"])
}
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
private func performSeek(
to positionMs: Int64,
resumePlayback: Bool? = nil,
completion: (() -> Void)? = nil
) {
let targetPositionMs = max(0, positionMs)
let shouldResumePlayback = resumePlayback ?? playbackIntent
// 必须在发起 seek 前清掉上一句的硬边界。否则从第 1 句右划到第 2 句时,
// AVPlayerItem 上的 forwardPlaybackEndTime 仍是第 1 句 endMs;seek 可能
// 被这个旧边界截断,UI 先显示第 2 句,然后立刻又回到第 1 句。
cancelSentenceBoundaryGuard()
clearForwardPlaybackEnd()
seekGeneration += 1
let generation = seekGeneration
pendingSeekPositionMs = targetPositionMs
// 立即切换句子锚点。否则 periodic observer 仍可能以旧句子为锚点,
// 在 seek 完成前后把新位置拉回旧句开头。
lastSentenceIndex = sentence(at: targetPositionMs)?.index
let time = CMTime(seconds: Double(targetPositionMs) / 1000, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
DispatchQueue.main.async {
guard let self, self.seekGeneration == generation else {
return
}
self.pendingSeekPositionMs = nil
self.tick()
if shouldResumePlayback {
self.player.play()
}
completion?()
// seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。
self.refreshSentenceBoundaryGuard(force: shouldResumePlayback)
self.tick()
}
}
tick()
}
private func durationMs(of item: AVPlayerItem) -> Int64 {
let seconds = item.duration.seconds
guard seconds.isFinite, seconds > 0 else {
return -1
}
return Int64(seconds * 1000)
}
private func bufferedPositionMs(of item: AVPlayerItem) -> Int64 {
guard let range = item.loadedTimeRanges.last?.timeRangeValue else {
return 0
}
let seconds = range.end.seconds
guard seconds.isFinite else {
return 0
}
return max(0, Int64(seconds * 1000))
}
private func currentSpeed() -> Float {
let rate = player.rate
return rate == 0 ? player.defaultRate : rate
}
private func playbackState(of item: AVPlayerItem, positionMs: Int64, durationMs: Int64) -> PlaybackState {
if item.status == .failed {
return .idle
}
switch player.timeControlStatus {
case .waitingToPlayAtSpecifiedRate:
return .buffering
case .playing:
return .ready
case .paused:
if durationMs > 0, positionMs >= durationMs {
return .ended
}
return .ready
@unknown default:
return .idle
}
}
private func sentence(at positionMs: Int64) -> SentenceBoundary? {
guard let item = mediaItem else {
return nil
}
return item.sentences.last { sentence in
positionMs >= sentence.startMs && positionMs < sentence.endMs
}
}
/// 与安卓端一致:非连续播放时到句尾回到句首;playCurrentSentenceAndStop 播完自动暂停。
private func enforceSentenceBoundary(positionMs: Int64) {
guard config.sentenceMode,
player.timeControlStatus == .playing,
let item = mediaItem,
!item.sentences.isEmpty else {
return
}
if stopAtSentenceEnd {
guard let index = stopAtSentenceEndIndex,
let sentence = item.sentences.first(where: { $0.index == index }) else {
return
}
if positionMs >= sentence.endMs {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
player.pause()
seekRaw(sentence.startMs)
}
return
}
if config.continuousPlayback {
return
}
guard let anchorIndex = lastSentenceIndex,
let anchor = item.sentences.first(where: { $0.index == anchorIndex }) else {
return
}
if positionMs >= anchor.endMs {
seekRaw(sentenceSeekPosition(anchor))
}
}
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() {
guard loopMode != .off else {
tick()
return
}
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
// 不能再被识别成整条视频结束。
guard pendingSeekPositionMs == nil else {
tick()
return
}
// 评测“播放原句”即使处于连续播放,也要用当前句硬边界,不能滑进下一句。
if stopAtSentenceEnd,
let index = stopAtSentenceEndIndex,
let sentence = mediaItem?.sentences.first(where: { $0.index == index }) {
applySentenceEndAction(sentence)
return
}
// 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束
// 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。
if config.sentenceMode,
!config.continuousPlayback,
let sentence = currentSentence,
currentPositionMs() + 30 >= sentence.endMs {
applySentenceEndAction(sentence)
return
}
// 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。
// 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。
guard isNaturalMediaEnd() else {
tick()
return
}
// 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停,
// 则不应因为到达 media end 而突然自动循环。
performSeek(to: .zero)
tick()
}
private func isNaturalMediaEnd() -> Bool {
guard let item = player.currentItem else {
return true
}
let durationMs = durationMs(of: item)
let positionMs = currentPositionMs()
if durationMs > 0 {
return positionMs + 30 >= durationMs
}
guard let sentence = currentSentence else {
return true
}
return positionMs + 30 >= sentence.endMs
}
// MARK: - 观察器
private func attachItemObservers(to item: AVPlayerItem) {
itemStatusObserver?.invalidate()
itemStatusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
DispatchQueue.main.async {
guard let self = self else {
return
}
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)
try? session.setActive(true)
}
}

View File

@@ -0,0 +1,186 @@
import AVFoundation
import SwiftUI
import UIKit
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
/// - 单击:播放 / 暂停
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
/// - 上划 / 下划:显示 / 隐藏字幕层(由 SwiftUI 横屏页接收)
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
struct PlayerSurfaceView: UIViewRepresentable {
let controller: PlayerController
var onVerticalSwipe: ((Bool) -> Void)?
func makeUIView(context: Context) -> VideoSurfaceView {
let view = VideoSurfaceView()
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.controller = controller
uiView.onVerticalSwipe = onVerticalSwipe
uiView.attach(player: controller.player)
}
}
final class VideoSurfaceView: UIView {
weak var controller: PlayerController?
var onVerticalSwipe: ((Bool) -> Void)?
private let videoLayer = AVPlayerLayer()
var playerLayer: AVPlayerLayer { videoLayer }
private let minSwipeDistance: CGFloat = 48
private let minSwipeVelocity: CGFloat = 160
private let longPressTimeout: TimeInterval = 0.5
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
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))
panRecognizer.minimumNumberOfTouches = 1
panRecognizer.maximumNumberOfTouches = 1
longPressRecognizer = UILongPressGestureRecognizer(
target: self,
action: #selector(handleLongPress)
)
longPressRecognizer.minimumPressDuration = longPressTimeout
tapRecognizer.require(toFail: panRecognizer)
tapRecognizer.require(toFail: longPressRecognizer)
panRecognizer.require(toFail: longPressRecognizer)
addGestureRecognizer(tapRecognizer)
addGestureRecognizer(panRecognizer)
addGestureRecognizer(longPressRecognizer)
isUserInteractionEnabled = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
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) {
let translation = recognizer.translation(in: self)
let velocity = recognizer.velocity(in: self)
let dx = translation.x
let dy = translation.y
switch recognizer.state {
case .began:
resetSwipeTracking()
case .changed:
evaluateSwipe(dx: dx, dy: dy, velocity: velocity)
case .ended, .cancelled, .failed:
if !didDispatchSwipe {
evaluateSwipe(dx: dx, dy: dy, velocity: velocity)
}
resetSwipeTracking()
default:
break
}
}
private func evaluateSwipe(dx: CGFloat, dy: CGFloat, velocity: CGPoint) {
guard !didDispatchSwipe else { return }
if swipeAxis == nil {
if abs(dx) > abs(dy) * 1.2 {
swipeAxis = .horizontal
} else if abs(dy) > abs(dx) * 1.2 {
swipeAxis = .vertical
} else {
return
}
}
switch swipeAxis {
case .vertical:
guard abs(dy) >= minSwipeDistance,
abs(velocity.y) >= minSwipeVelocity else {
return
}
didDispatchSwipe = true
onVerticalSwipe?(dy < 0)
case .horizontal:
guard abs(dx) >= abs(dy),
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) {
let location = recognizer.location(in: self)
switch recognizer.state {
case .began:
controller?.beginLongPressSpeed(atX: location.x, width: bounds.width)
case .changed:
controller?.updateLongPressSpeed(atX: location.x, width: bounds.width)
case .ended, .cancelled:
controller?.endLongPressSpeed()
default:
break
}
}
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.518",
"green" : "0.722",
"red" : "0.082"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,52 @@
<?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">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleDisplayName</key>
<string>MynaTalk</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>需要麦克风权限才能进行录音评测</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
CFBundleDisplayName = "八哥口语";

View File

@@ -0,0 +1,159 @@
import AVFoundation
import Foundation
enum AudioRecorderSessionError: LocalizedError {
case inputUnavailable
case startFailed
case conversionFailed
var errorDescription: String? {
switch self {
case .inputUnavailable:
return "未检测到麦克风输入"
case .startFailed:
return "无法开始录制:请确认麦克风未被其他应用占用后重试"
case .conversionFailed:
return "录音文件转码失败"
}
}
}
/// 基于 AVAudioEngine 的录音会话。
///
/// AVAudioRecorder 在 iOS 26 真机上存在 record() 恒返回 false 的已知问题
/// (Apple 开发者论坛多个报告),因此这里改用 AVAudioEngine 采集 PCM,
/// 停止录音时再转码为 16kHz 单声道 AAC(m4a),与服务端评测接口保持一致。
final class AudioRecorderSession {
private let engine = AVAudioEngine()
private let outputURL: URL
private let pcmURL: URL
private var inputFile: AVAudioFile?
private var tapInstalled = false
private(set) var isRunning = false
private(set) var recordDuration: TimeInterval = 0
/// 本次录音的原始 PCM 文件(caf),停止录音后仍保留,
/// 供配音合成直接合并,避免在 iOS 26 上读回 AAC(AVAudioFile 存在 'fmt?' 问题)。
var rawRecordingURL: URL { pcmURL }
init(outputURL: URL) {
self.outputURL = outputURL
let stamp = Int(Date().timeIntervalSince1970 * 1000)
self.pcmURL = FileManager.default.temporaryDirectory
.appendingPathComponent("raw-\(stamp).caf")
}
/// 激活音频会话并开始采集。失败时抛出错误,可在重新激活会话后重试。
func start() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
guard session.isInputAvailable else {
throw AudioRecorderSessionError.inputUnavailable
}
let input = engine.inputNode
if !tapInstalled {
let inputFormat = input.outputFormat(forBus: 0)
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
throw AudioRecorderSessionError.inputUnavailable
}
let file = try AVAudioFile(forWriting: pcmURL, settings: inputFormat.settings)
inputFile = file
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
guard let self = self else { return }
do {
try self.inputFile?.write(from: buffer)
} catch {
// 单个缓冲写入失败时忽略,继续录音
}
}
tapInstalled = true
}
engine.prepare()
do {
try engine.start()
} catch {
throw AudioRecorderSessionError.startFailed
}
isRunning = true
}
/// 停止录音,将 PCM 转码为 m4a 写入 outputURL。
func stop() throws {
guard isRunning else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
isRunning = false
tapInstalled = false
inputFile = nil
try finalizeToAAC()
}
/// 放弃本次录音并清理临时文件。
func cancel() {
if isRunning {
engine.inputNode.removeTap(onBus: 0)
engine.stop()
isRunning = false
tapInstalled = false
inputFile = nil
}
try? FileManager.default.removeItem(at: pcmURL)
try? FileManager.default.removeItem(at: outputURL)
}
private func finalizeToAAC() throws {
let inputFile = try AVAudioFile(forReading: pcmURL)
recordDuration = Double(inputFile.length) / inputFile.processingFormat.sampleRate
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
]
let outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings)
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFile.processingFormat) else {
throw AudioRecorderSessionError.conversionFailed
}
let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
if inputFile.framePosition >= inputFile.length {
outStatus.pointee = .endOfStream
return nil
}
let remaining = inputFile.length - inputFile.framePosition
let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining)))
guard frames > 0,
let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
outStatus.pointee = .noDataNow
return nil
}
do {
try inputFile.read(into: buffer)
outStatus.pointee = .haveData
return buffer
} catch {
outStatus.pointee = .noDataNow
return nil
}
}
let capacity = AVAudioFrameCount(min(4096, max(1, Int(inputFile.length))))
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFile.processingFormat, frameCapacity: capacity) else {
throw AudioRecorderSessionError.conversionFailed
}
while true {
var conversionError: NSError?
let status = converter.convert(to: outputBuffer, error: &conversionError, withInputFrom: inputBlock)
switch status {
case .haveData:
try outputFile.write(from: outputBuffer)
case .inputRanDry, .endOfStream:
return
case .error:
throw conversionError ?? AudioRecorderSessionError.conversionFailed
default:
return
}
}
}
}

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

@@ -0,0 +1,154 @@
import AVFoundation
import Foundation
enum DubbingCompositorError: LocalizedError {
case inputReadFailed(String?)
case outputWriteFailed(String?)
case convertFailed(String?)
var errorDescription: String? {
switch self {
case .inputReadFailed(let detail):
return detail.map { "无法读取录音音频(\($0))" } ?? "无法读取录音音频"
case .outputWriteFailed(let detail):
return detail.map { "无法写入合成音频(\($0))" } ?? "无法写入合成音频"
case .convertFailed(let detail):
return detail.map { "无法转换音频格式(\($0))" } ?? "无法转换音频格式"
}
}
}
enum DubbingCompositor {
/// 合并多段录音为一段 16kHz 单声道 AAC(m4a)。
///
/// 不使用 AVMutableComposition + AVAssetExportSession:iOS 26 / macOS 26 上
/// AVAssetExportSession 对纯音频资源导出会失败(AVError -11800,底层 'fmt?'),
/// AVAudioFile 读回 AAC 也会失败(CheckClientFormatSet / 'fmt?')。
/// 因此传入录音时保留的原始 PCM(caf),解码拼接后直接编码 AAC,
/// 与录音转码走同一条已验证可用的链路。
static func merge(urls: [URL], outputURL: URL) async throws {
do {
try mergeSync(urls: urls, outputURL: outputURL)
} catch {
try? FileManager.default.removeItem(at: outputURL)
throw error
}
}
private static func mergeSync(urls: [URL], outputURL: URL) throws {
try? FileManager.default.removeItem(at: outputURL)
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
]
let outputFile: AVAudioFile
do {
outputFile = try AVAudioFile(forWriting: outputURL, settings: settings)
} catch {
throw DubbingCompositorError.outputWriteFailed(error.localizedDescription)
}
let targetFormat = outputFile.processingFormat
for url in urls {
let inputFile: AVAudioFile
do {
inputFile = try AVAudioFile(forReading: url)
} catch {
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
}
guard inputFile.length > 0 else {
throw DubbingCompositorError.inputReadFailed("录音为空")
}
let inputFormat = inputFile.processingFormat
if inputFormat.sampleRate == targetFormat.sampleRate,
inputFormat.channelCount == targetFormat.channelCount {
try appendStream(from: inputFile, to: outputFile)
} else {
try appendConverted(from: inputFile, to: outputFile, targetFormat: targetFormat)
}
}
}
private static func appendStream(from inputFile: AVAudioFile, to outputFile: AVAudioFile) throws {
var remaining = inputFile.length
while remaining > 0 {
let frames = AVAudioFrameCount(min(remaining, 8192))
guard let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
throw DubbingCompositorError.convertFailed("无法分配解码缓冲")
}
do {
try inputFile.read(into: buffer)
} catch {
throw DubbingCompositorError.inputReadFailed(error.localizedDescription)
}
guard buffer.frameLength > 0 else {
throw DubbingCompositorError.inputReadFailed("录音文件读取出错(读取到空帧)")
}
do {
try outputFile.write(from: buffer)
} catch {
throw DubbingCompositorError.outputWriteFailed(error.localizedDescription)
}
remaining -= AVAudioFramePosition(buffer.frameLength)
}
}
private static func appendConverted(
from inputFile: AVAudioFile,
to outputFile: AVAudioFile,
targetFormat: AVAudioFormat
) throws {
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: targetFormat) else {
throw DubbingCompositorError.convertFailed("无法创建格式转换器")
}
let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
if inputFile.framePosition >= inputFile.length {
outStatus.pointee = .endOfStream
return nil
}
let remaining = inputFile.length - inputFile.framePosition
let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining)))
guard frames > 0,
let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
outStatus.pointee = .noDataNow
return nil
}
do {
try inputFile.read(into: buffer)
guard buffer.frameLength > 0 else {
outStatus.pointee = .endOfStream
return nil
}
outStatus.pointee = .haveData
return buffer
} catch {
outStatus.pointee = .noDataNow
return nil
}
}
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: 8192) else {
throw DubbingCompositorError.convertFailed("无法分配转码缓冲")
}
while true {
var error: NSError?
let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
switch status {
case .haveData:
do {
try outputFile.write(from: outputBuffer)
} catch {
throw DubbingCompositorError.outputWriteFailed(error.localizedDescription)
}
case .inputRanDry, .endOfStream:
return
case .error:
if let error {
throw DubbingCompositorError.convertFailed(error.localizedDescription)
}
throw DubbingCompositorError.convertFailed(nil)
default:
return
}
}
}
}

View File

@@ -0,0 +1,136 @@
import Foundation
enum HTTPClientError: LocalizedError {
case invalidURL
case badStatus(Int, String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "无效的服务地址"
case .badStatus(let code, let body):
return "HTTP \(code): \(body)"
}
}
}
struct HTTPResult {
let statusCode: Int
let body: Data
let effectiveBaseURL: String
}
enum HTTPClient {
static func getJSON<T: Decodable>(
_ type: T.Type,
baseURL: String,
path: String,
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: headers
)
guard (200...299).contains(result.statusCode) else {
let body = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, body)
}
let value = try JSONDecoder().decode(T.self, from: result.body)
return (value, result.effectiveBaseURL)
}
static func execute(
baseURL: String,
path: String,
method: String,
timeout: TimeInterval,
headers: [String: String] = [:],
body: Data? = nil
) async throws -> HTTPResult {
let trimmedBase = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let base = URL(string: trimmedBase), base.scheme != nil, base.host != nil else {
throw HTTPClientError.invalidURL
}
let endpoint = URL(string: path, relativeTo: base) ?? base
do {
return try await perform(
url: endpoint,
method: method,
timeout: timeout,
headers: headers,
body: body,
effectiveBaseURL: trimmedBase
)
} catch {
// 与安卓端一致:HTTPS 因 TLS 问题失败时,降级用同一请求重试 HTTP。
guard AppConfig.allowHTTPFallback,
endpoint.scheme == "https",
isTLSError(error) else {
throw error
}
var baseComponents = URLComponents(string: trimmedBase)
baseComponents?.scheme = "http"
guard let httpBase = baseComponents?.string,
let httpURL = URL(string: path, relativeTo: URL(string: httpBase)) else {
throw error
}
return try await perform(
url: httpURL,
method: method,
timeout: timeout,
headers: headers,
body: body,
effectiveBaseURL: httpBase
)
}
}
private static func perform(
url: URL,
method: String,
timeout: TimeInterval,
headers: [String: String],
body: Data?,
effectiveBaseURL: String
) async throws -> HTTPResult {
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = timeout
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
request.httpBody = body
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HTTPClientError.invalidURL
}
return HTTPResult(statusCode: http.statusCode, body: data, effectiveBaseURL: effectiveBaseURL)
}
private static func isTLSError(_ error: Error) -> Bool {
let nsError = error as NSError
let tlsCodes: [Int] = [
NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid,
NSURLErrorClientCertificateRejected,
NSURLErrorClientCertificateRequired,
NSURLErrorCannotLoadFromNetwork,
]
if tlsCodes.contains(nsError.code) {
return true
}
let message = (nsError.localizedDescription + " " + (nsError.localizedFailureReason ?? ""))
.lowercased()
return message.contains("ssl") || message.contains("tls") || message.contains("certificate")
}
}

View File

@@ -0,0 +1,229 @@
import CryptoKit
import Foundation
enum ImitationAssessorError: LocalizedError {
case invalidHash
case cannotReadRecording
var errorDescription: String? {
switch self {
case .invalidHash:
return "视频 id 必须是 64 位 SHA-256 十六进制字符串"
case .cannotReadRecording:
return "无法读取录音文件"
}
}
}
struct DubShareUploadRequest {
let videoHash: String
let title: String
let segments: [(index: Int, audioURL: URL)]
let scores: [Int: ImitationAssessmentResult]
}
enum ImitationAssessor {
/// 分块计算文件的 SHA-256(与安卓端一致,适合大视频文件)。
static func sha256Hex(of fileURL: URL) throws -> String {
let handle: FileHandle
do {
handle = try FileHandle(forReadingFrom: fileURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? handle.close() }
var hasher = SHA256()
while true {
guard let data = try? handle.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// 复制到缓存目录的同时计算 SHA-256(单次读取完成两件事)。
static func copyAndHash(from source: URL, to destination: URL) throws -> String {
let input: FileHandle
do {
input = try FileHandle(forReadingFrom: source)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? input.close() }
try? FileManager.default.removeItem(at: destination)
FileManager.default.createFile(atPath: destination.path, contents: nil)
let output = try FileHandle(forWritingTo: destination)
defer { try? output.close() }
var hasher = SHA256()
while true {
guard let data = try input.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
try output.write(contentsOf: data)
}
try output.synchronize()
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// 上传一句录音到服务端 MOSS 评测接口(multipart/form-data,与安卓端字段一致)。
static func assess(request: ImitationAssessmentRequest) async throws -> ImitationAssessmentResult {
let hash = request.mediaId.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw ImitationAssessorError.invalidHash
}
let path = "api/v1/videos/\(hash)/sentences/\(request.sentence.index)/assessments"
let boundary = "----OralTrainer-\(UUID().uuidString)"
var body = Data()
appendField(&body, boundary: boundary, name: "language", value: request.locale ?? request.sentence.language ?? "")
let recordingData: Data
do {
recordingData = try Data(contentsOf: request.recordingURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
let filename = request.recordingURL.lastPathComponent
let mimeType = request.recordingURL.pathExtension.lowercased() == "wav" ? "audio/wav" : "audio/m4a"
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: headers,
body: body
)
guard (200...299).contains(result.statusCode) else {
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, responseBody)
}
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body)
}
static func uploadDubShare(request: DubShareUploadRequest) async throws -> String {
let hash = request.videoHash.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw ImitationAssessorError.invalidHash
}
guard !request.segments.isEmpty else {
throw ImitationAssessorError.cannotReadRecording
}
let boundary = "----OralTrainer-\(UUID().uuidString)"
var body = Data()
appendField(&body, boundary: boundary, name: "video_hash", value: hash)
appendField(&body, boundary: boundary, name: "title", value: request.title.isEmpty ? "我的口语配音" : request.title)
let segmentItems = request.segments
.sorted { $0.index < $1.index }
.map { "{\"sentence_index\":\($0.index)}" }
appendField(&body, boundary: boundary, name: "segments", value: "[\(segmentItems.joined(separator: ","))]")
let scoreItems = request.segments
.sorted { $0.index < $1.index }
.compactMap { segment -> String? in
guard let score = request.scores[segment.index] else { return nil }
var fields = ["\"sentence_index\":\(segment.index)"]
for (key, value) in [
("overall_score", score.overallScore as Float?),
("content_score", score.contentScore),
("fluency_score", score.fluencyScore),
("duration_score", score.durationScore),
("pause_score", score.pauseScore),
("speech_rate_score", score.speechRateScore),
] {
if let value = value {
fields.append("\"\(key)\":\(value)")
}
}
if let text = score.recognizedText {
let escaped = text
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
fields.append("\"recognized_text\":\"\(escaped)\"")
}
return "{\(fields.joined(separator: ","))}"
}
appendField(&body, boundary: boundary, name: "scores", value: "[\(scoreItems.joined(separator: ","))]")
for segment in request.segments.sorted(by: { $0.index < $1.index }) {
let audioData: Data
do {
audioData = try Data(contentsOf: segment.audioURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
appendFile(
&body,
boundary: boundary,
name: "files",
filename: "dub-\(segment.index).m4a",
mimeType: "audio/mp4",
data: audioData
)
}
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: headers,
body: body
)
guard (200...299).contains(result.statusCode) else {
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, responseBody)
}
struct ShareResponse: Decodable {
let shareId: String
private enum CodingKeys: String, CodingKey {
case shareId = "share_id"
}
}
let response = try JSONDecoder().decode(ShareResponse.self, from: result.body)
let base = AppConfig.serverBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\(base)/dub-shares/\(response.shareId)"
}
private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
body.append(Data(value.utf8))
body.append(Data("\r\n".utf8))
}
private static func appendFile(
_ body: inout Data,
boundary: String,
name: String,
filename: String,
mimeType: String,
data: Data
) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".utf8))
body.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
body.append(data)
body.append(Data("\r\n".utf8))
}
}

View File

@@ -0,0 +1,222 @@
import Foundation
enum SentenceBoundaryAPIError: LocalizedError {
case invalidHash
case hashMismatch
var errorDescription: String? {
switch self {
case .invalidHash:
return "video_hash 必须是 64 位 SHA-256 十六进制字符串"
case .hashMismatch:
return "服务端返回的句边界哈希与请求不一致"
}
}
}
enum SentenceBoundaryAPI {
static func fetch(videoHash: String) async throws -> SentenceBoundaryDocument {
let hash = videoHash.lowercased()
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,
additionalHeaders: headers
)
guard result.value.videoHash.lowercased() == hash else {
throw SentenceBoundaryAPIError.hashMismatch
}
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,18 @@
import Foundation
struct VideoCatalogResponse: Decodable {
let videos: [TrainingVideoSummary]
}
enum VideoCatalogAPI {
static func fetch() async throws -> (videos: [TrainingVideoSummary], baseURL: String) {
let result = try await HTTPClient.getJSON(
VideoCatalogResponse.self,
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos",
timeout: AppConfig.readTimeout
)
let ready = result.value.videos.filter { $0.status == "ready" }
return (ready, result.effectiveBaseURL)
}
}

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