Compare commits
118 Commits
2d48c127f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d143eb7fed | |||
| a87228cdc9 | |||
| 031f3c6f21 | |||
| 03e7987c7c | |||
| ed8df744f9 | |||
| 9b7c7d726b | |||
| fe21617c63 | |||
| 5428c42ffd | |||
| ae84347912 | |||
| 38894ba80c | |||
| 532d9900ba | |||
| 176d722c77 | |||
| 4746331515 | |||
| b28f809c46 | |||
| 4fd0fd36a4 | |||
| d5609c5c42 | |||
| beb29429d2 | |||
| 9daea7b807 | |||
| 6bd64ee4a9 | |||
| 138d446d73 | |||
| 7b63c4eab6 | |||
| 1a5a756e82 | |||
| 55fad2c0cd | |||
| 7fae2bb8c9 | |||
| 37ee850ee1 | |||
| 2a7f24b1bd | |||
| 3aaa9f4229 | |||
| 9c78bb9b04 | |||
| 53e947c506 | |||
| 5328898971 | |||
| f810cfedd4 | |||
| 1499db0ed7 | |||
| 5856832eca | |||
| 55d6860cc0 | |||
| ce8b765103 | |||
| 96d0ff5318 | |||
| 93232a64fa | |||
| e2cc793bdb | |||
| 8414f36cca | |||
| ab4ff6c25a | |||
| 67c7ad3d0f | |||
| 4b71c6c615 | |||
| 07261d092b | |||
| 2b663d50ad | |||
| 65b6617a5f | |||
| c841aa091d | |||
| f10915e020 | |||
| 9980f78cfc | |||
| 39896a5c24 | |||
| 3908947ac2 | |||
| 9d956183c7 | |||
| fb64343a9f | |||
| d0d43794f3 | |||
| 9230db3b69 | |||
| aca36c78c1 | |||
| 0baff0b447 | |||
| 701148e00e | |||
| 7d843e8ff1 | |||
| 6d9f9a5468 | |||
| f928dd5c68 | |||
| ff1341e191 | |||
| a38679d4a0 | |||
| cb87cf8cb1 | |||
| 1adba4d227 | |||
| 09fbe8a2de | |||
| b5934f21f5 | |||
| f9f65988b7 | |||
| b335430c18 | |||
| cb5573da73 | |||
| 53aed9e220 | |||
| 85a5e76d6f | |||
| daa73e2b98 | |||
| 3652ebda07 | |||
| 4a61afd5a9 | |||
| e80a770847 | |||
| a652a39b6c | |||
| 4ae77de307 | |||
| f2042e638f | |||
| 88811be98d | |||
| 7e4c0f5f90 | |||
| 637101bcdb | |||
| 0b85a2ed57 | |||
| 7cdc637527 | |||
| 0d0b82e095 | |||
| 508a26ba02 | |||
| a99c0cfd17 | |||
| abc052e42d | |||
| 997bd9eed8 | |||
| 856aa38026 | |||
| 6de1d4a3d0 | |||
| f93bb8d1ff | |||
| bb677ea8cf | |||
| 76b0332035 | |||
| e470fa2dae | |||
| b1d3a3bd5a | |||
| a6297e2f9f | |||
| 1b6090e44c | |||
| 14d9c56d34 | |||
| f80da8a5f6 | |||
| ccd8ec16bb | |||
| 9feb876b7a | |||
| 28674def97 | |||
| 8d4a81b4f5 | |||
| 3f86e8f44e | |||
| aa8baab7c0 | |||
| 7556fd0a7e | |||
| 1fb3146590 | |||
| f5c6717186 | |||
| a36e776343 | |||
| 7c81976b2b | |||
| a8031e2e3b | |||
| d27c2e229e | |||
| 624b834632 | |||
| c50c82a90f | |||
| 47ca72a62a | |||
| c8581c72b4 | |||
| 773f14751f | |||
| c90df866c1 |
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
24
README.md
@@ -10,8 +10,17 @@ pip 安装依赖后即可直接播放视频。
|
||||
- 播放 / 暂停 / 停止、上一首 / 下一首
|
||||
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
|
||||
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
|
||||
- 按句跳转:勾选后 `←` / `→` 自动播放上一句/下一句(基于音频静音自动识别
|
||||
句边界,无需联网,分析结果自动缓存)
|
||||
- 按句跳转:先点“生成句边界”。生成时会先按 SHA-256 查询线上是否已有同一
|
||||
视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能
|
||||
勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256
|
||||
缓存结果;生成过程有进度条。
|
||||
- 画面字幕:句边界就绪后,当前句会叠在视频画面底部。可勾选“中文字幕”
|
||||
(快捷键 `C`)显示或关闭中文翻译;翻译由服务端生成并缓存。
|
||||
- 导出字幕:句边界就绪后可点“导出字幕”,默认保存为 UTF-8(带 BOM)的 `.ass`(中英同一条字幕避免重叠),也可另存 `.srt`。
|
||||
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
||||
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
||||
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
||||
`~/.mediaplayer_logs/mediaplayer.log`,不包含登录令牌。
|
||||
- 音量调节、静音,0.5x - 2.0x 变速播放
|
||||
- 三种循环模式:关 / 单曲循环 / 列表循环
|
||||
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
||||
@@ -77,6 +86,8 @@ pip install -r requirements.txt
|
||||
| `N` / `P` | 下一首 / 上一首 |
|
||||
| `[` / `]` | 减速 / 加速(0.5x - 2.0x) |
|
||||
| `L` | 切换循环模式(关 / 单曲 / 列表) |
|
||||
| `C` | 开 / 关中文字幕 |
|
||||
| 双击画面 | 进入 / 退出全屏 |
|
||||
| `F` | 视频全屏切换 |
|
||||
| `Esc` | 退出全屏 |
|
||||
| `Ctrl+O` | 打开文件(macOS 亦支持 `Command+O`) |
|
||||
@@ -98,7 +109,8 @@ pip install -r requirements.txt
|
||||
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
|
||||
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
|
||||
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
|
||||
“按句跳转”的开关状态同样会自动保存。
|
||||
“按句跳转”和“中文字幕”的开关状态同样会自动保存。句边界就绪后,原文会叠在
|
||||
视频画面底部;打开中文字幕时会向服务器请求翻译并一并显示。
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -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 自动
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -65,7 +65,8 @@ 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()
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
@@ -76,7 +77,7 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
val boundary = "----OralTrainer-${UUID.randomUUID()}"
|
||||
return try {
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
@@ -88,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
}
|
||||
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
@@ -104,6 +108,7 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeFormField(output: BufferedOutputStream, boundary: String, name: String, value: String) {
|
||||
output.write("--$boundary\r\n".toByteArray(Charsets.UTF_8))
|
||||
|
||||
@@ -98,7 +98,8 @@ class SentenceBoundaryApi internal constructor(
|
||||
}
|
||||
|
||||
private fun fetchBlocking(videoHash: String): SentenceBoundaryApiResult {
|
||||
val endpoint = baseUri.buildUpon()
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
@@ -106,12 +107,16 @@ class SentenceBoundaryApi internal constructor(
|
||||
.appendPath("sentence-boundaries")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
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
|
||||
@@ -127,6 +132,7 @@ class SentenceBoundaryApi internal constructor(
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResponse(
|
||||
body: String,
|
||||
@@ -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() }
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ data class SentenceBoundary @JvmOverloads constructor(
|
||||
val text: String? = null,
|
||||
val language: String? = null,
|
||||
val referenceSpeechDurationMs: Long? = null,
|
||||
val zhText: String? = null,
|
||||
) {
|
||||
init {
|
||||
require(index >= 0) { "Sentence index must be non-negative." }
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
package cn.learningpad.oraltrainer.sdk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class AuthUser(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val nickname: String,
|
||||
)
|
||||
|
||||
data class AuthSession(
|
||||
val token: String,
|
||||
val user: AuthUser,
|
||||
)
|
||||
|
||||
data class Course(
|
||||
val videoHash: String,
|
||||
val title: String,
|
||||
val durationMs: Long?,
|
||||
val language: String?,
|
||||
val sentenceCount: Int,
|
||||
val streamUrl: String,
|
||||
val enrolled: Boolean,
|
||||
) {
|
||||
fun toVideoSummary(): TrainingVideoSummary {
|
||||
return TrainingVideoSummary(
|
||||
videoHash = videoHash,
|
||||
title = title,
|
||||
streamUri = android.net.Uri.parse(streamUrl),
|
||||
durationMs = durationMs,
|
||||
sizeBytes = 0L,
|
||||
language = language,
|
||||
sentenceCount = sentenceCount,
|
||||
status = "ready",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UserResult(
|
||||
val attemptId: String,
|
||||
val videoHash: String,
|
||||
val courseTitle: String,
|
||||
val sentenceIndex: Int,
|
||||
val sentenceText: String,
|
||||
val overallScore: Float,
|
||||
val passed: Boolean,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class UserDubShare(
|
||||
val shareId: String,
|
||||
val videoHash: String,
|
||||
val courseTitle: String,
|
||||
val title: String,
|
||||
val segmentCount: Int,
|
||||
val averageScore: Float,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class DubShareSegmentDetail(
|
||||
val index: Int,
|
||||
val overallScore: Float,
|
||||
val recognizedText: String,
|
||||
val scoreDetails: Map<String, Float>,
|
||||
val audioUrl: String,
|
||||
)
|
||||
|
||||
data class DubShareDetail(
|
||||
val shareId: String,
|
||||
val videoHash: String,
|
||||
val title: String,
|
||||
val segments: List<DubShareSegmentDetail>,
|
||||
)
|
||||
|
||||
class UserApiException(
|
||||
val statusCode: Int,
|
||||
responseBody: String,
|
||||
) : IOException("HTTP $statusCode: ${responseBody.take(300)}")
|
||||
|
||||
class UserApi internal constructor(
|
||||
context: Context,
|
||||
private val config: OralTrainerSdkConfig,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val baseUri = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||
|
||||
init {
|
||||
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
|
||||
"sentenceBoundaryApiBaseUrl must use http or https."
|
||||
}
|
||||
}
|
||||
|
||||
fun register(
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String? = null,
|
||||
phone: String,
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
requestRegisterBlocking(
|
||||
"api/v1/auth/register",
|
||||
username = username,
|
||||
password = password,
|
||||
nickname = nickname,
|
||||
phone = phone,
|
||||
)
|
||||
}
|
||||
|
||||
fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) }
|
||||
|
||||
fun ssoExchange(
|
||||
accessToken: String,
|
||||
onComplete: (Result<SsoExchangeResult>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
val root = JSONObject().put("access_token", accessToken)
|
||||
val response = request("api/v1/auth/sso/exchange", body = root.toString(), method = "POST")
|
||||
parseSsoExchangeResult(JSONObject(response))
|
||||
}
|
||||
|
||||
fun ssoBindPhone(
|
||||
token: String,
|
||||
phone: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
val root = JSONObject().put("phone", phone)
|
||||
request("api/v1/auth/sso/bind-phone", token = token, body = root.toString(), method = "POST")
|
||||
}
|
||||
|
||||
fun courses(
|
||||
token: String,
|
||||
onComplete: (Result<List<Course>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "api/v1/courses")) }
|
||||
|
||||
fun enroll(
|
||||
token: String,
|
||||
videoHash: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("api/v1/courses/$videoHash/enroll", token, method = "POST")
|
||||
}
|
||||
|
||||
fun results(
|
||||
token: String,
|
||||
onComplete: (Result<List<UserResult>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "api/v1/me/results")) }
|
||||
|
||||
fun dubShares(
|
||||
token: String,
|
||||
onComplete: (Result<List<UserDubShare>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) }
|
||||
|
||||
fun deleteResult(
|
||||
token: String,
|
||||
attemptId: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("api/v1/me/results/$attemptId", token, method = "DELETE")
|
||||
}
|
||||
|
||||
fun deleteDubShare(
|
||||
token: String,
|
||||
shareId: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("api/v1/me/dub-shares/$shareId", token, method = "DELETE")
|
||||
}
|
||||
|
||||
fun dubShareDetail(
|
||||
shareId: String,
|
||||
onComplete: (Result<DubShareDetail>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
parseDubShareDetail(JSONObject(request("api/v1/dub-shares/$shareId", method = "GET")))
|
||||
}
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun <T> submit(
|
||||
onComplete: (Result<T>) -> Unit,
|
||||
operation: () -> T,
|
||||
): CancellableRequest {
|
||||
val future: Future<*> = executor.submit {
|
||||
post(onComplete, runCatching(operation))
|
||||
}
|
||||
return CancellableRequest(future)
|
||||
}
|
||||
|
||||
private fun <T> submitObject(
|
||||
onComplete: (Result<T>) -> Unit,
|
||||
token: String,
|
||||
operation: (String) -> T,
|
||||
): CancellableRequest = submit(onComplete) { operation(token) }
|
||||
|
||||
private fun submitUnit(
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
token: String,
|
||||
operation: (String) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
operation(token)
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun <T> post(onComplete: (Result<T>) -> Unit, result: Result<T>) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { onComplete(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBlocking(path: String, username: String, password: String, auth: Boolean): AuthSession {
|
||||
val root = JSONObject().put("username", username).put("password", password)
|
||||
val response = request(path, body = root.toString(), method = "POST")
|
||||
return parseSession(JSONObject(response))
|
||||
}
|
||||
|
||||
private fun requestRegisterBlocking(
|
||||
path: String,
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String?,
|
||||
phone: String,
|
||||
): AuthSession {
|
||||
val cleanedPhone = phone.trim()
|
||||
require(cleanedPhone.isNotEmpty()) { "请填写手机号" }
|
||||
require(PHONE_PATTERN.matches(cleanedPhone)) { "请输入有效的 11 位手机号" }
|
||||
val root = JSONObject()
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) }
|
||||
root.put("phone", cleanedPhone)
|
||||
val response = request(path, body = root.toString(), method = "POST")
|
||||
return parseSession(JSONObject(response))
|
||||
}
|
||||
|
||||
private fun getBlocking(token: String, path: String): JSONArray {
|
||||
val body = request(path, token = token, method = "GET")
|
||||
return JSONArray(body)
|
||||
}
|
||||
|
||||
private fun parseCourses(items: JSONArray): List<Course> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
Course(
|
||||
videoHash = item.getString("video_hash"),
|
||||
title = item.getString("title"),
|
||||
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
|
||||
language = if (item.isNull("language")) null else item.getString("language"),
|
||||
sentenceCount = item.getInt("sentence_count"),
|
||||
streamUrl = item.getString("stream_url"),
|
||||
enrolled = item.getBoolean("enrolled"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseResults(items: JSONArray): List<UserResult> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
UserResult(
|
||||
attemptId = item.getString("attempt_id"),
|
||||
videoHash = item.getString("video_hash"),
|
||||
courseTitle = item.getString("course_title"),
|
||||
sentenceIndex = item.getInt("sentence_index"),
|
||||
sentenceText = item.getString("sentence_text"),
|
||||
overallScore = item.getDouble("overall_score").toFloat(),
|
||||
passed = item.getBoolean("passed"),
|
||||
createdAt = item.getString("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDubShares(items: JSONArray): List<UserDubShare> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
UserDubShare(
|
||||
shareId = item.getString("share_id"),
|
||||
videoHash = item.getString("video_hash"),
|
||||
courseTitle = item.getString("course_title"),
|
||||
title = item.getString("title"),
|
||||
segmentCount = item.getInt("segment_count"),
|
||||
averageScore = item.getDouble("average_score").toFloat(),
|
||||
createdAt = item.getString("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDubShareDetail(root: JSONObject): DubShareDetail {
|
||||
val segmentsJson = root.optJSONArray("segments") ?: JSONArray()
|
||||
val segments = List(segmentsJson.length()) { index ->
|
||||
val item = segmentsJson.getJSONObject(index)
|
||||
val detailsJson = item.optJSONObject("score_details") ?: JSONObject()
|
||||
val scoreDetails = buildMap<String, Float> {
|
||||
for (key in detailsJson.keys()) {
|
||||
if (!detailsJson.isNull(key)) {
|
||||
put(key, detailsJson.optDouble(key).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
DubShareSegmentDetail(
|
||||
index = item.getInt("index"),
|
||||
overallScore = item.optDouble("overall_score", 0.0).toFloat(),
|
||||
recognizedText = item.optString("recognized_text"),
|
||||
scoreDetails = scoreDetails,
|
||||
audioUrl = item.getString("audio_url"),
|
||||
)
|
||||
}
|
||||
return DubShareDetail(
|
||||
shareId = root.getString("share_id"),
|
||||
videoHash = root.getString("video_hash"),
|
||||
title = root.optString("title"),
|
||||
segments = segments,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
path: String,
|
||||
token: String? = null,
|
||||
body: String? = null,
|
||||
method: String = body?.let { "POST" } ?: "GET",
|
||||
): String {
|
||||
val endpoint = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||
.buildUpon().appendEncodedPath(path).build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
token?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw UserApiException(statusCode, text)
|
||||
return text
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSession(root: JSONObject): AuthSession {
|
||||
val user = root.getJSONObject("user")
|
||||
return AuthSession(
|
||||
token = root.getString("token"),
|
||||
user = AuthUser(
|
||||
id = user.getString("id"),
|
||||
username = user.getString("username"),
|
||||
nickname = user.getString("nickname"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSsoExchangeResult(root: JSONObject): SsoExchangeResult {
|
||||
val user = root.getJSONObject("user")
|
||||
return SsoExchangeResult(
|
||||
token = root.getString("token"),
|
||||
user = AuthUser(
|
||||
id = user.getString("id"),
|
||||
username = user.getString("username"),
|
||||
nickname = user.getString("nickname"),
|
||||
),
|
||||
phoneBound = root.optBoolean("phone_bound", false),
|
||||
isNewUser = root.optBoolean("is_new_user", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SsoExchangeResult(
|
||||
val token: String,
|
||||
val user: AuthUser,
|
||||
val phoneBound: Boolean,
|
||||
val isNewUser: Boolean,
|
||||
)
|
||||
|
||||
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")
|
||||
@@ -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,9 +66,10 @@ class VideoCatalogApi internal constructor(
|
||||
}
|
||||
|
||||
private fun fetchBlocking(): List<TrainingVideoSummary> {
|
||||
val endpoint = URL(URL(baseUrl), "api/v1/videos")
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = URL(URL(effectiveBaseUrl), "api/v1/videos")
|
||||
val connection = endpoint.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
@@ -79,25 +79,32 @@ class VideoCatalogApi internal constructor(
|
||||
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)
|
||||
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"),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
enum class Module {
|
||||
COURSE,
|
||||
TRAIN,
|
||||
READING,
|
||||
TEST,
|
||||
MINE,
|
||||
}
|
||||
|
||||
internal const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
|
||||
internal const val ASSESSMENT_API_KEY =
|
||||
"fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
|
||||
|
||||
internal const val SSO_LOGIN_URL = "https://d1kt.cn/login"
|
||||
|
||||
internal const val RECORD_AUDIO_REQUEST = 1002
|
||||
internal const val PROGRESS_MAX = 1000
|
||||
|
||||
internal val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
|
||||
internal class AssessmentState {
|
||||
private val scores = mutableMapOf<Int, ImitationAssessmentResult>()
|
||||
|
||||
fun store(sentenceIndex: Int, result: ImitationAssessmentResult) {
|
||||
scores[sentenceIndex] = result
|
||||
}
|
||||
|
||||
fun get(sentenceIndex: Int): ImitationAssessmentResult? = scores[sentenceIndex]
|
||||
|
||||
fun replaceAll(values: Map<Int, ImitationAssessmentResult>) {
|
||||
scores.clear()
|
||||
scores.putAll(values)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
scores.clear()
|
||||
}
|
||||
|
||||
fun toMap(): Map<Int, ImitationAssessmentResult> = scores.toMap()
|
||||
|
||||
val size: Int
|
||||
get() = scores.size
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
|
||||
internal class AttemptRecorder(private val context: Context) {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var file: File? = null
|
||||
|
||||
val isRecording: Boolean
|
||||
get() = recorder != null
|
||||
|
||||
fun start(cacheDir: File): File {
|
||||
release()
|
||||
|
||||
val outputFile = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a")
|
||||
val mediaRecorder = createRecorder()
|
||||
try {
|
||||
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
mediaRecorder.setAudioSamplingRate(16_000)
|
||||
mediaRecorder.setAudioEncodingBitRate(96_000)
|
||||
mediaRecorder.setOutputFile(outputFile.absolutePath)
|
||||
mediaRecorder.prepare()
|
||||
mediaRecorder.start()
|
||||
recorder = mediaRecorder
|
||||
file = outputFile
|
||||
return outputFile
|
||||
} catch (error: Throwable) {
|
||||
runCatching { mediaRecorder.release() }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): File {
|
||||
val activeRecorder = requireNotNull(recorder) { "当前没有录音" }
|
||||
val outputFile = requireNotNull(file) { "录音文件不存在" }
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
return outputFile
|
||||
} catch (error: Throwable) {
|
||||
outputFile.delete()
|
||||
throw error
|
||||
} finally {
|
||||
runCatching { activeRecorder.release() }
|
||||
recorder = null
|
||||
file = null
|
||||
}
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
recorder?.let { mediaRecorder ->
|
||||
runCatching { mediaRecorder.stop() }
|
||||
runCatching { mediaRecorder.release() }
|
||||
}
|
||||
recorder = null
|
||||
file?.delete()
|
||||
file = null
|
||||
}
|
||||
|
||||
fun release() = discard()
|
||||
|
||||
private fun createRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
import cn.learningpad.oraltrainer.sdk.AuthUser
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
internal class AuthCoordinator(
|
||||
private val activity: Activity,
|
||||
private val userApi: UserApi,
|
||||
private val onAuthenticated: (AuthSession) -> Unit,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private var screen = Screen.LOGIN
|
||||
private var pendingSsoToken: String? = null
|
||||
private var webView: WebView? = null
|
||||
private var loginStatusMessage = ""
|
||||
|
||||
fun createView(): View = when (screen) {
|
||||
Screen.LOGIN -> createLoginView()
|
||||
Screen.REGISTER -> createRegisterView()
|
||||
Screen.SSO -> createSsoView()
|
||||
Screen.PHONE_BINDING -> createPhoneBindingView()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
pendingSsoToken = null
|
||||
loginStatusMessage = ""
|
||||
screen = Screen.LOGIN
|
||||
}
|
||||
|
||||
private fun createLoginView(): View = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
setPadding(28.dp, 28.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(TextView(activity).apply {
|
||||
text = "八哥口语"
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "请先注册或登录后开始学习"
|
||||
textSize = 15f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
})
|
||||
|
||||
val usernameInput = EditText(activity).apply {
|
||||
hint = "用户名"
|
||||
setSingleLine(true)
|
||||
}
|
||||
val passwordInput = EditText(activity).apply {
|
||||
hint = "密码(至少 8 位)"
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
setSingleLine(true)
|
||||
}
|
||||
val statusText = statusText(loginStatusMessage)
|
||||
addView(usernameInput)
|
||||
addView(passwordInput)
|
||||
addView(primaryButton("登录") {
|
||||
statusText.text = "登录中..."
|
||||
userApi.login(
|
||||
usernameInput.text?.toString()?.trim().orEmpty(),
|
||||
passwordInput.text?.toString().orEmpty(),
|
||||
) { result ->
|
||||
result.fold(
|
||||
onSuccess = onAuthenticated,
|
||||
onFailure = { error -> statusText.text = "登录失败:${error.message}" },
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).withMargins(0, 20.dp, 0, 8.dp)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "注册"
|
||||
setOnClickListener {
|
||||
screen = Screen.REGISTER
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "── 或 ──"
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 12.dp, 0, 12.dp)
|
||||
})
|
||||
addView(primaryButton("第一课堂一键登录", SampleTheme.button) {
|
||||
screen = Screen.SSO
|
||||
activity.setContentView(createView())
|
||||
})
|
||||
addView(statusText)
|
||||
}
|
||||
|
||||
private fun createRegisterView(): View = ScrollView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(title("注册账号", 28f))
|
||||
addView(subtitle("填写以下信息完成注册"))
|
||||
|
||||
val usernameInput = labeledInput("用户名", "3-50 位字母、数字或下划线")
|
||||
val passwordInput = labeledInput(
|
||||
"密码",
|
||||
"至少 8 位",
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD,
|
||||
)
|
||||
val nicknameInput = labeledInput("姓名", "你的真实姓名或昵称")
|
||||
val phoneInput = labeledInput("手机号", "请输入 11 位手机号", InputType.TYPE_CLASS_PHONE)
|
||||
val statusText = statusText()
|
||||
|
||||
addView(primaryButton("注册") {
|
||||
val username = usernameInput.text?.toString()?.trim().orEmpty()
|
||||
val password = passwordInput.text?.toString().orEmpty()
|
||||
val nickname = nicknameInput.text?.toString()?.trim().orEmpty()
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
when {
|
||||
username.length < 3 -> statusText.text = "用户名至少 3 个字符"
|
||||
password.length < 8 -> statusText.text = "密码至少 8 位"
|
||||
nickname.isBlank() -> statusText.text = "请填写姓名"
|
||||
!PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号"
|
||||
else -> {
|
||||
statusText.text = "注册中..."
|
||||
userApi.register(username, password, nickname, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = onAuthenticated,
|
||||
onFailure = { error ->
|
||||
statusText.text = "注册失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(0, 20.dp, 0, 8.dp)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "返回登录"
|
||||
setOnClickListener {
|
||||
screen = Screen.LOGIN
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
addView(statusText)
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun createSsoView(): View = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
|
||||
addView(Button(activity).apply {
|
||||
text = "取消登录"
|
||||
setOnClickListener {
|
||||
leaveSso(Screen.LOGIN)
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
|
||||
webView = WebView(activity).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
loadUrl(SSO_LOGIN_URL)
|
||||
}
|
||||
addView(
|
||||
webView,
|
||||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f),
|
||||
)
|
||||
addView(primaryButton("已登录,点击继续") { readJwtAndLogin() }.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(8.dp, 8.dp, 8.dp, 8.dp)
|
||||
})
|
||||
}
|
||||
|
||||
private fun readJwtAndLogin() {
|
||||
webView?.evaluateJavascript(JWT_SCRIPT) { result ->
|
||||
val jwt = result?.removeSurrounding("\"").orEmpty()
|
||||
if (jwt.isBlank() || jwt == "null") {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, "未找到登录凭证,请先在页面中登录", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@evaluateJavascript
|
||||
}
|
||||
userApi.ssoExchange(jwt) { exchangeResult ->
|
||||
exchangeResult.fold(
|
||||
onSuccess = { ssoResult ->
|
||||
activity.runOnUiThread {
|
||||
leaveSso(if (ssoResult.phoneBound) Screen.LOGIN else Screen.PHONE_BINDING)
|
||||
pendingSsoToken = ssoResult.token
|
||||
if (ssoResult.phoneBound) {
|
||||
onAuthenticated(AuthSession(ssoResult.token, ssoResult.user))
|
||||
} else {
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
activity.runOnUiThread {
|
||||
loginStatusMessage = "SSO 登录失败:${error.message}"
|
||||
leaveSso(Screen.LOGIN)
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPhoneBindingView(): View = ScrollView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(title("绑定手机号", 28f))
|
||||
addView(subtitle("已通过第一课堂登录,请绑定手机号后继续"))
|
||||
val phoneInput = labeledInput(
|
||||
"手机号",
|
||||
"请输入 11 位手机号",
|
||||
InputType.TYPE_CLASS_PHONE,
|
||||
)
|
||||
val statusText = statusText()
|
||||
addView(primaryButton("绑定并继续") {
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
val token = pendingSsoToken
|
||||
when {
|
||||
!PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号"
|
||||
token.isNullOrBlank() -> statusText.text = "登录状态已过期,请重新登录"
|
||||
else -> {
|
||||
statusText.text = "绑定中..."
|
||||
userApi.ssoBindPhone(token, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
pendingSsoToken = null
|
||||
screen = Screen.LOGIN
|
||||
onAuthenticated(AuthSession(token, AuthUser("", "", "")))
|
||||
},
|
||||
onFailure = { error ->
|
||||
statusText.text = "绑定失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(0, 20.dp, 0, 0)
|
||||
})
|
||||
addView(statusText)
|
||||
})
|
||||
}
|
||||
|
||||
private fun LinearLayout.labeledInput(
|
||||
label: String,
|
||||
hint: String,
|
||||
inputType: Int? = null,
|
||||
): EditText {
|
||||
addView(TextView(activity).apply {
|
||||
text = label
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 12.dp, 0, 4.dp)
|
||||
})
|
||||
return EditText(activity).apply {
|
||||
this.hint = hint
|
||||
inputType?.let { this.inputType = it }
|
||||
setSingleLine(true)
|
||||
}.also(::addView)
|
||||
}
|
||||
|
||||
private fun title(value: String, size: Float) = TextView(activity).apply {
|
||||
text = value
|
||||
textSize = size
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
}
|
||||
|
||||
private fun subtitle(value: String) = TextView(activity).apply {
|
||||
text = value
|
||||
textSize = 14f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
}
|
||||
|
||||
private fun statusText(value: String = "") = TextView(activity).apply {
|
||||
text = value
|
||||
setTextColor(SampleTheme.accentLight)
|
||||
textSize = 13f
|
||||
setPadding(0, 14.dp, 0, 0)
|
||||
}
|
||||
|
||||
private fun primaryButton(
|
||||
label: String,
|
||||
color: Int = SampleTheme.accent,
|
||||
action: () -> Unit,
|
||||
) = Button(activity).apply {
|
||||
text = label
|
||||
background = rounded(color, 8f)
|
||||
setTextColor(Color.WHITE)
|
||||
setOnClickListener { action() }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
}
|
||||
|
||||
private fun leaveSso(destination: Screen) {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
screen = destination
|
||||
}
|
||||
|
||||
private enum class Screen {
|
||||
LOGIN,
|
||||
REGISTER,
|
||||
SSO,
|
||||
PHONE_BINDING,
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")
|
||||
private const val JWT_SCRIPT =
|
||||
"(function(){ try { return localStorage.getItem('token') || " +
|
||||
"sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
|
||||
internal class AuthSessionStore(context: Context) {
|
||||
private val preferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var session: AuthSession?
|
||||
get() {
|
||||
val token = preferences.getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val nickname = preferences.getString(KEY_NICKNAME, "").orEmpty()
|
||||
return AuthSession(token, cn.learningpad.oraltrainer.sdk.AuthUser("", "", nickname))
|
||||
}
|
||||
set(value) {
|
||||
val editor = preferences.edit()
|
||||
if (value == null) {
|
||||
editor.clear()
|
||||
} else {
|
||||
editor.putString(KEY_TOKEN, value.token)
|
||||
editor.putString(KEY_NICKNAME, value.user.nickname)
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean
|
||||
get() = session != null
|
||||
|
||||
private companion object {
|
||||
private const val PREFERENCES_NAME = "oral_trainer_auth"
|
||||
private const val KEY_TOKEN = "token"
|
||||
private const val KEY_NICKNAME = "nickname"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
/**
|
||||
* Owns course list loading, enrollment, and remote sentence-boundary refresh.
|
||||
* UI updates stay in the host via [Host].
|
||||
*/
|
||||
internal class CourseCatalogController(
|
||||
private val sdk: OralTrainerSdk,
|
||||
private val controller: OralTrainerController,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val tokenProvider: () -> String,
|
||||
private val host: Host,
|
||||
) {
|
||||
interface Host {
|
||||
fun onCatalogChanged()
|
||||
fun onCatalogStatus(text: String)
|
||||
fun onCatalogLoadingPlaceholder()
|
||||
fun onPlayerLoading(video: TrainingVideoSummary)
|
||||
fun onStatus(text: String)
|
||||
fun onSentencePlaceholder(meta: String, text: String)
|
||||
fun refreshPlayerUi()
|
||||
fun openTrainingModule()
|
||||
fun syncSentenceExplanations(videoHash: String, sentences: List<SentenceBoundary>)
|
||||
}
|
||||
|
||||
fun loadCatalog() {
|
||||
loadCatalog(refreshBoundaries = false)
|
||||
}
|
||||
|
||||
fun refreshCourses() {
|
||||
if (screenState.catalogIsRefreshing) return
|
||||
loadCatalog(refreshBoundaries = true)
|
||||
}
|
||||
|
||||
private fun loadCatalog(refreshBoundaries: Boolean) {
|
||||
val generation = screenState.beginCatalogRequest()
|
||||
screenState.catalogIsRefreshing = refreshBoundaries
|
||||
screenState.catalogStatus = "正在同步"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogLoadingPlaceholder()
|
||||
sdk.userApi.courses(tokenProvider()) { result ->
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return@courses
|
||||
result.fold(
|
||||
onSuccess = { courses ->
|
||||
screenState.catalogVideos = courses.map { it.toVideoSummary() }
|
||||
screenState.courseEnrollment.clear()
|
||||
courses.forEach { screenState.courseEnrollment[it.videoHash] = it.enrolled }
|
||||
if (refreshBoundaries) {
|
||||
screenState.catalogStatus = "${courses.size} 个课程,正在刷新句界"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
refreshAllCourseBoundaries(generation, index = 0, succeeded = 0, failed = 0)
|
||||
} else {
|
||||
screenState.catalogIsRefreshing = false
|
||||
screenState.catalogStatus = "${screenState.catalogVideos.size} 个课程"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
screenState.catalogIsRefreshing = false
|
||||
screenState.catalogStatus = "课程加载失败:${it.message}"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
screenState.courseEnrollment.clear()
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAllCourseBoundaries(
|
||||
generation: Int,
|
||||
index: Int,
|
||||
succeeded: Int,
|
||||
failed: Int,
|
||||
) {
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return
|
||||
val videos = screenState.catalogVideos
|
||||
if (index >= videos.size) {
|
||||
screenState.catalogIsRefreshing = false
|
||||
screenState.catalogStatus = when {
|
||||
failed == 0 -> "${videos.size} 个课程 · 句界已刷新"
|
||||
succeeded == 0 -> "${videos.size} 个课程 · 句界刷新失败"
|
||||
else -> "${videos.size} 个课程 · 句界部分刷新(失败 $failed)"
|
||||
}
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
return
|
||||
}
|
||||
|
||||
val video = videos[index]
|
||||
screenState.catalogStatus =
|
||||
"正在刷新句界 ${index + 1}/${videos.size}"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
|
||||
override fun onSuccess(result: SentenceBoundaryApiResult) {
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return
|
||||
screenState.courseBoundaries[result.videoHash] = result
|
||||
host.syncSentenceExplanations(result.videoHash, result.sentences)
|
||||
updateCatalogVideo(video.videoHash, result)
|
||||
if (screenState.activeItemId == result.videoHash) {
|
||||
controller.updateSentences(result.videoHash, result.sentences)
|
||||
screenState.currentSentenceCount = result.sentences.size
|
||||
host.refreshPlayerUi()
|
||||
}
|
||||
host.onCatalogChanged()
|
||||
refreshAllCourseBoundaries(generation, index + 1, succeeded + 1, failed)
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (!screenState.isCurrentCatalogRequest(generation)) return
|
||||
refreshAllCourseBoundaries(generation, index + 1, succeeded, failed + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateCatalogVideo(videoHash: String, result: SentenceBoundaryApiResult) {
|
||||
screenState.catalogVideos = screenState.catalogVideos.map { video ->
|
||||
if (video.videoHash != videoHash) {
|
||||
video
|
||||
} else {
|
||||
video.copy(
|
||||
durationMs = result.durationMs,
|
||||
sentenceCount = result.sentences.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (screenState.currentCourse?.videoHash == videoHash) {
|
||||
screenState.currentCourse = screenState.catalogVideos.firstOrNull { it.videoHash == videoHash }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUserData(target: Module, onMine: () -> Unit) {
|
||||
when (target) {
|
||||
Module.COURSE -> loadCatalog()
|
||||
Module.MINE -> onMine()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCourse(video: TrainingVideoSummary) {
|
||||
screenState.currentCourse = video
|
||||
if (screenState.courseEnrollment[video.videoHash] != true) {
|
||||
enrollCourse(video)
|
||||
}
|
||||
screenState.activeModule = Module.TRAIN
|
||||
screenState.landscapeSubtitleVisible = true
|
||||
host.openTrainingModule()
|
||||
loadRemoteVideo(video)
|
||||
}
|
||||
|
||||
fun enrollCourse(video: TrainingVideoSummary) {
|
||||
sdk.userApi.enroll(tokenProvider(), video.videoHash) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
screenState.courseEnrollment[video.videoHash] = true
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
onFailure = {
|
||||
host.onCatalogStatus("选修失败:${it.message}")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRemoteVideo(video: TrainingVideoSummary) {
|
||||
screenState.currentCourse = video
|
||||
screenState.activeItemId = video.videoHash
|
||||
host.onPlayerLoading(video)
|
||||
host.onCatalogChanged()
|
||||
|
||||
val cachedBoundary = screenState.courseBoundaries[video.videoHash]
|
||||
if (cachedBoundary != null) {
|
||||
host.syncSentenceExplanations(cachedBoundary.videoHash, cachedBoundary.sentences)
|
||||
}
|
||||
controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty()))
|
||||
screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount
|
||||
if (cachedBoundary == null) {
|
||||
refreshSentenceBoundaries(video, background = false)
|
||||
} else {
|
||||
host.refreshPlayerUi()
|
||||
host.onStatus("课程已就绪:${cachedBoundary.sentences.size} 句")
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSentenceBoundaries(
|
||||
video: TrainingVideoSummary,
|
||||
background: Boolean,
|
||||
) {
|
||||
val requestGeneration = screenState.beginBoundaryRequest()
|
||||
host.onStatus(
|
||||
if (background) "正在刷新句子边界..." else "正在加载云端课程...",
|
||||
)
|
||||
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
|
||||
override fun onSuccess(result: SentenceBoundaryApiResult) {
|
||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||
return
|
||||
}
|
||||
screenState.courseBoundaries[result.videoHash] = result
|
||||
host.syncSentenceExplanations(result.videoHash, result.sentences)
|
||||
updateCatalogVideo(video.videoHash, result)
|
||||
screenState.currentSentenceCount = result.sentences.size
|
||||
controller.updateSentences(video.videoHash, result.sentences)
|
||||
host.onCatalogChanged()
|
||||
host.refreshPlayerUi()
|
||||
host.onStatus("课程已就绪:${result.sentences.size} 句")
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||
return
|
||||
}
|
||||
host.onStatus("句子边界加载失败:${error.message.orEmpty()}")
|
||||
host.onSentencePlaceholder("暂无句子边界", "")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Persists per-course dubbing audio and assessment scores under app filesDir. */
|
||||
internal class DubbingHistoryStore(context: Context) {
|
||||
private val rootDir = File(context.applicationContext.filesDir, "dubbing-history")
|
||||
|
||||
fun loadSnapshot(videoHash: String): Snapshot {
|
||||
val normalized = normalize(videoHash) ?: return Snapshot()
|
||||
val dir = videoDir(normalized)
|
||||
if (!dir.isDirectory) {
|
||||
return Snapshot()
|
||||
}
|
||||
val segments = dir.listFiles()
|
||||
?.mapNotNull { file ->
|
||||
val match = SEGMENT_PATTERN.matchEntire(file.name) ?: return@mapNotNull null
|
||||
if (!file.isFile || file.length() <= 0L) return@mapNotNull null
|
||||
match.groupValues[1].toInt() to file
|
||||
}
|
||||
?.toMap()
|
||||
.orEmpty()
|
||||
val merged = File(dir, MERGED_FILE_NAME).takeIf { it.isFile && it.length() > 0L }
|
||||
val scores = readScores(File(dir, SCORES_FILE_NAME))
|
||||
return Snapshot(segments = segments, scores = scores, mergedFile = merged)
|
||||
}
|
||||
|
||||
fun saveSegment(videoHash: String, sentenceIndex: Int, source: File): File {
|
||||
val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" }
|
||||
val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a")
|
||||
if (source.canonicalFile != dest.canonicalFile) {
|
||||
source.copyTo(dest, overwrite = true)
|
||||
}
|
||||
// Any new recording invalidates the previous merged export.
|
||||
File(videoDir(normalized), MERGED_FILE_NAME).delete()
|
||||
return dest
|
||||
}
|
||||
|
||||
fun saveScores(videoHash: String, scores: Map<Int, ImitationAssessmentResult>) {
|
||||
val normalized = normalize(videoHash) ?: return
|
||||
val file = File(videoDir(normalized), SCORES_FILE_NAME)
|
||||
val root = JSONObject()
|
||||
scores.toSortedMap().forEach { (index, result) ->
|
||||
root.put(index.toString(), encodeScore(result))
|
||||
}
|
||||
file.writeText(root.toString())
|
||||
}
|
||||
|
||||
fun saveMerged(videoHash: String, source: File): File {
|
||||
val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" }
|
||||
val dest = File(videoDir(normalized), MERGED_FILE_NAME)
|
||||
if (source.canonicalFile != dest.canonicalFile) {
|
||||
source.copyTo(dest, overwrite = true)
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
fun clear(videoHash: String) {
|
||||
val normalized = normalize(videoHash) ?: return
|
||||
videoDir(normalized).deleteRecursively()
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads one public share-audio URL into local history.
|
||||
* Returns the saved file, or null when the download fails.
|
||||
*/
|
||||
fun downloadSegmentAudio(
|
||||
baseUrl: String,
|
||||
videoHash: String,
|
||||
sentenceIndex: Int,
|
||||
audioUrl: String,
|
||||
): File? {
|
||||
val normalized = normalize(videoHash) ?: return null
|
||||
val absolute = when {
|
||||
audioUrl.startsWith("http://") || audioUrl.startsWith("https://") -> audioUrl
|
||||
audioUrl.startsWith("/") -> baseUrl.trimEnd('/') + audioUrl
|
||||
else -> baseUrl.trimEnd('/') + "/" + audioUrl
|
||||
}
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
connection = URL(absolute).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.instanceFollowRedirects = true
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
return null
|
||||
}
|
||||
val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a")
|
||||
connection.inputStream.use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
if (dest.length() <= 0L) {
|
||||
dest.delete()
|
||||
null
|
||||
} else {
|
||||
dest
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoDir(videoHash: String): File =
|
||||
File(rootDir, videoHash).also { it.mkdirs() }
|
||||
|
||||
private fun readScores(file: File): Map<Int, ImitationAssessmentResult> {
|
||||
if (!file.isFile) {
|
||||
return emptyMap()
|
||||
}
|
||||
return runCatching {
|
||||
val root = JSONObject(file.readText())
|
||||
buildMap {
|
||||
for (key in root.keys()) {
|
||||
val index = key.toIntOrNull() ?: continue
|
||||
val value = root.optJSONObject(key) ?: continue
|
||||
put(index, decodeScore(value))
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
data class Snapshot(
|
||||
val segments: Map<Int, File> = emptyMap(),
|
||||
val scores: Map<Int, ImitationAssessmentResult> = emptyMap(),
|
||||
val mergedFile: File? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SCORES_FILE_NAME = "scores.json"
|
||||
private const val MERGED_FILE_NAME = "merged.m4a"
|
||||
private val SEGMENT_PATTERN = Regex("""segment-(\d+)\.m4a""")
|
||||
|
||||
fun normalize(videoHash: String): String? {
|
||||
val value = videoHash.trim().lowercase(Locale.US)
|
||||
return value.takeIf { PlaybackFormat.isVideoHash(it) }
|
||||
}
|
||||
|
||||
fun encodeScore(result: ImitationAssessmentResult): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("overall_score", result.overallScore.toDouble())
|
||||
result.pronunciationScore?.let { put("pronunciation_score", it.toDouble()) }
|
||||
result.fluencyScore?.let { put("fluency_score", it.toDouble()) }
|
||||
result.completenessScore?.let { put("completeness_score", it.toDouble()) }
|
||||
result.feedback?.let { put("feedback", it) }
|
||||
if (result.details.isNotEmpty()) {
|
||||
put(
|
||||
"details",
|
||||
JSONObject().also { details ->
|
||||
result.details.forEach { (key, value) -> details.put(key, value) }
|
||||
},
|
||||
)
|
||||
}
|
||||
result.passed?.let { put("passed", it) }
|
||||
result.contentScore?.let { put("content_score", it.toDouble()) }
|
||||
result.durationScore?.let { put("duration_score", it.toDouble()) }
|
||||
result.pauseScore?.let { put("pause_score", it.toDouble()) }
|
||||
result.speechRateScore?.let { put("speech_rate_score", it.toDouble()) }
|
||||
result.durationRatio?.let { put("duration_ratio", it.toDouble()) }
|
||||
result.referenceText?.let { put("reference_text", it) }
|
||||
result.recognizedText?.let { put("recognized_text", it) }
|
||||
if (result.missingTokens.isNotEmpty()) {
|
||||
put("missing_tokens", JSONArray(result.missingTokens))
|
||||
}
|
||||
if (result.extraTokens.isNotEmpty()) {
|
||||
put("extra_tokens", JSONArray(result.extraTokens))
|
||||
}
|
||||
if (result.substitutions.isNotEmpty()) {
|
||||
put(
|
||||
"substitutions",
|
||||
JSONArray().also { array ->
|
||||
result.substitutions.forEach { (expected, actual) ->
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("expected", expected)
|
||||
.put("actual", actual),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeScore(root: JSONObject): ImitationAssessmentResult {
|
||||
val substitutions = buildList {
|
||||
val array = root.optJSONArray("substitutions") ?: return@buildList
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.optJSONObject(index) ?: continue
|
||||
add(item.optString("expected") to item.optString("actual"))
|
||||
}
|
||||
}
|
||||
val details = buildMap {
|
||||
val objectValue = root.optJSONObject("details") ?: return@buildMap
|
||||
for (key in objectValue.keys()) {
|
||||
put(key, objectValue.optString(key))
|
||||
}
|
||||
}
|
||||
return ImitationAssessmentResult(
|
||||
overallScore = root.optDouble("overall_score", 0.0).toFloat(),
|
||||
pronunciationScore = root.optNullableFloat("pronunciation_score"),
|
||||
fluencyScore = root.optNullableFloat("fluency_score"),
|
||||
completenessScore = root.optNullableFloat("completeness_score"),
|
||||
feedback = root.optString("feedback").ifBlank { null },
|
||||
details = details,
|
||||
passed = if (root.has("passed")) root.optBoolean("passed") else null,
|
||||
contentScore = root.optNullableFloat("content_score"),
|
||||
durationScore = root.optNullableFloat("duration_score"),
|
||||
pauseScore = root.optNullableFloat("pause_score"),
|
||||
speechRateScore = root.optNullableFloat("speech_rate_score"),
|
||||
durationRatio = root.optNullableFloat("duration_ratio"),
|
||||
referenceText = root.optString("reference_text").ifBlank { null },
|
||||
recognizedText = root.optString("recognized_text").ifBlank { null },
|
||||
missingTokens = root.optStringList("missing_tokens"),
|
||||
extraTokens = root.optStringList("extra_tokens"),
|
||||
substitutions = substitutions,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.optNullableFloat(name: String): Float? {
|
||||
if (!has(name) || isNull(name)) return null
|
||||
return optDouble(name).toFloat()
|
||||
}
|
||||
|
||||
private fun JSONObject.optStringList(name: String): List<String> {
|
||||
val array = optJSONArray(name) ?: return emptyList()
|
||||
return buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val value = array.optString(index)
|
||||
if (value.isNotBlank()) add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.max
|
||||
|
||||
internal class DubbingMerger(private val cacheDir: File) {
|
||||
fun merge(segments: List<Pair<Int, File>>): File {
|
||||
require(segments.isNotEmpty()) { "没有可合成的配音" }
|
||||
|
||||
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
|
||||
var muxer: MediaMuxer? = null
|
||||
try {
|
||||
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
var audioTrack = -1
|
||||
var presentationTimeUs = 0L
|
||||
|
||||
segments.forEach { (_, segment) ->
|
||||
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
|
||||
try {
|
||||
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
|
||||
extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)
|
||||
?.startsWith("audio/") == true
|
||||
} ?: throw IllegalStateException("录音没有音频轨道")
|
||||
extractor.selectTrack(trackIndex)
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
if (audioTrack == -1) {
|
||||
audioTrack = muxer.addTrack(format)
|
||||
muxer.start()
|
||||
}
|
||||
val buffer = ByteBuffer.allocateDirect(
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
} else {
|
||||
64 * 1024
|
||||
}
|
||||
)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
info.set(0, sampleSize, presentationTimeUs, extractor.sampleFlags)
|
||||
muxer.writeSampleData(audioTrack, buffer, info)
|
||||
presentationTimeUs += 23_000L
|
||||
extractor.advance()
|
||||
}
|
||||
presentationTimeUs = max(presentationTimeUs, segmentDurationUs(segment))
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
require(audioTrack != -1) { "录音没有可用音频轨道" }
|
||||
muxer.stop()
|
||||
muxer.release()
|
||||
muxer = null
|
||||
return output
|
||||
} catch (error: Throwable) {
|
||||
runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
output.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun segmentDurationUs(segment: File): Long {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
retriever.setDataSource(segment.absolutePath)
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull()?.times(1000L) ?: 0L
|
||||
} catch (error: Throwable) {
|
||||
0L
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import java.io.File
|
||||
|
||||
internal class DubbingPlayback(
|
||||
context: Context,
|
||||
private val mainHandler: Handler = Handler(Looper.getMainLooper()),
|
||||
) {
|
||||
private val player = ExoPlayer.Builder(context).build()
|
||||
private var listener: Player.Listener? = null
|
||||
private var generation = 0
|
||||
|
||||
val isActive: Boolean
|
||||
get() = player.isPlaying || player.playWhenReady
|
||||
|
||||
fun start(
|
||||
audioFile: File,
|
||||
controller: OralTrainerController,
|
||||
sentenceStartMs: Long,
|
||||
sentenceIndex: Int,
|
||||
onComplete: () -> Unit,
|
||||
) {
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
val playbackGeneration = ++generation
|
||||
listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState != Player.STATE_ENDED) return
|
||||
mainHandler.postDelayed({
|
||||
if (generation == playbackGeneration) {
|
||||
stop(controller)
|
||||
onComplete()
|
||||
}
|
||||
}, 250L)
|
||||
}
|
||||
}.also(player::addListener)
|
||||
|
||||
// Mute original audio and drive the video for this sentence only.
|
||||
controller.setVolume(0f)
|
||||
controller.playSentenceAndStop(sentenceStartMs, sentenceIndex)
|
||||
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
|
||||
player.prepare()
|
||||
player.volume = 1f
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
fun stop(controller: OralTrainerController) {
|
||||
generation++
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
controller.pause()
|
||||
controller.setVolume(1f)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
generation++
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.os.StrictMode
|
||||
import java.io.DataOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class DubbingShareApi {
|
||||
fun upload(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
videoHash: String,
|
||||
videoTitle: String,
|
||||
segments: Map<Int, java.io.File>,
|
||||
scores: Map<Int, cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult>,
|
||||
): String {
|
||||
require(token.isNotBlank()) { "Login required." }
|
||||
val previousPolicy = StrictMode.getThreadPolicy()
|
||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
||||
val boundary = "oral-trainer-${System.currentTimeMillis()}"
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection = URL(URL(baseUrl), "/api/v1/dub-shares").openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.setRequestProperty("Authorization", "Bearer $token")
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
|
||||
DataOutputStream(connection.outputStream).use { output ->
|
||||
fun text(name: String, value: String) {
|
||||
output.writeBytes("--$boundary\r\n")
|
||||
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
|
||||
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
|
||||
}
|
||||
text("video_hash", videoHash.lowercase(Locale.US))
|
||||
text("title", videoTitle.ifBlank { "我的口语配音" })
|
||||
text(
|
||||
"segments",
|
||||
segments.keys.sorted().joinToString(",", "[", "]") {
|
||||
"{\"sentence_index\":$it}"
|
||||
},
|
||||
)
|
||||
text(
|
||||
"scores",
|
||||
segments.keys.sorted().mapNotNull { index ->
|
||||
scores[index]?.let { score -> index to score }
|
||||
}.joinToString(",", "[", "]") { (index, score) ->
|
||||
buildString {
|
||||
append("{\"sentence_index\":")
|
||||
append(index)
|
||||
score.overallScore?.let { append(",\"overall_score\":$it") }
|
||||
listOf(
|
||||
"content_score" to score.contentScore,
|
||||
"fluency_score" to score.fluencyScore,
|
||||
"duration_score" to score.durationScore,
|
||||
"pause_score" to score.pauseScore,
|
||||
"speech_rate_score" to score.speechRateScore,
|
||||
).forEach { (name, value) ->
|
||||
value?.let { append(",\"$name\":$it") }
|
||||
}
|
||||
score.recognizedText?.let {
|
||||
append(",\"recognized_text\":\"")
|
||||
append(it.replace("\\", "\\\\").replace("\"", "\\\""))
|
||||
append("\"")
|
||||
}
|
||||
append("}")
|
||||
}
|
||||
},
|
||||
)
|
||||
segments.toSortedMap().forEach { (index, file) ->
|
||||
output.writeBytes("--$boundary\r\n")
|
||||
output.writeBytes(
|
||||
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
|
||||
)
|
||||
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
|
||||
file.inputStream().use { input -> input.copyTo(output) }
|
||||
output.writeBytes("\r\n")
|
||||
}
|
||||
output.writeBytes("--$boundary--\r\n")
|
||||
}
|
||||
|
||||
val code = connection.responseCode
|
||||
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
|
||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
if (code !in 200..299) {
|
||||
throw IllegalStateException(friendlyShareError(code, body))
|
||||
}
|
||||
val response = JSONObject(body).getString("share_id")
|
||||
return "$baseUrl/dub-shares/$response"
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
StrictMode.setThreadPolicy(previousPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun friendlyShareError(code: Int, body: String): String {
|
||||
val detail = runCatching { JSONObject(body).optString("detail") }.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: body
|
||||
return when {
|
||||
code == 401 ||
|
||||
detail.contains("Login required", ignoreCase = true) ||
|
||||
detail.contains("sign in", ignoreCase = true) ||
|
||||
detail.contains("Session expired", ignoreCase = true) ->
|
||||
"还未登录或登录已过期,请重新登录后再分享"
|
||||
detail.isNotBlank() -> "HTTP $code: $detail"
|
||||
else -> "HTTP $code"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class DubbingState {
|
||||
val segments = mutableMapOf<Int, File>()
|
||||
var mergedFile: File? = null
|
||||
private set
|
||||
|
||||
fun storeSegment(sentenceIndex: Int, file: File) {
|
||||
val previous = segments.put(sentenceIndex, file)
|
||||
if (previous != null && previous.absolutePath != file.absolutePath) {
|
||||
previous.delete()
|
||||
}
|
||||
mergedFile = null
|
||||
}
|
||||
|
||||
fun setMergedFile(file: File?) {
|
||||
mergedFile = file
|
||||
}
|
||||
|
||||
fun replaceAll(newSegments: Map<Int, File>, merged: File?) {
|
||||
val keep = newSegments.values.map { it.absolutePath }.toHashSet()
|
||||
segments.values
|
||||
.filter { it.absolutePath !in keep }
|
||||
.forEach { /* keep history files on disk; only drop memory refs */ }
|
||||
segments.clear()
|
||||
segments.putAll(newSegments)
|
||||
mergedFile = merged?.takeIf { it.exists() }
|
||||
}
|
||||
|
||||
fun clearMemory() {
|
||||
segments.clear()
|
||||
mergedFile = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.widget.TextView
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
/** Renders common LLM Markdown into a TextView without extra libraries. */
|
||||
internal object MarkdownText {
|
||||
private val heading = Regex("^(#{1,6})\\s*(.*)$")
|
||||
private val unordered = Regex("^[-*+]\\s+(.*)$")
|
||||
private val ordered = Regex("^\\d+[.)]\\s+(.*)$")
|
||||
private val boldItalic = Regex("\\*\\*\\*(.+?)\\*\\*\\*")
|
||||
private val bold = Regex("(\\*\\*|__)(.+?)\\1")
|
||||
private val italic = Regex("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)")
|
||||
private val italicUnderscore = Regex("(?<!\\w)_(?!_)(.+?)(?<!_)_(?!\\w)")
|
||||
private val inlineCode = Regex("`([^`]+)`")
|
||||
private val image = Regex("!\\[([^\\]]*)]\\(([^)]+)\\)")
|
||||
private val link = Regex("\\[([^\\]]+)]\\(([^)]+)\\)")
|
||||
|
||||
fun set(view: TextView, markdown: String) {
|
||||
view.text = render(markdown)
|
||||
}
|
||||
|
||||
fun render(markdown: String): CharSequence {
|
||||
return HtmlCompat.fromHtml(toHtml(markdown), HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
}
|
||||
|
||||
internal fun toHtml(markdown: String): String {
|
||||
val normalized = markdown.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val html = StringBuilder()
|
||||
var inFence = false
|
||||
val code = StringBuilder()
|
||||
var listTag: String? = null
|
||||
|
||||
fun closeList() {
|
||||
listTag?.let { html.append("</$it>") }
|
||||
listTag = null
|
||||
}
|
||||
|
||||
fun openList(tag: String) {
|
||||
if (listTag != tag) {
|
||||
closeList()
|
||||
listTag = tag
|
||||
html.append('<').append(tag).append('>')
|
||||
}
|
||||
}
|
||||
|
||||
for (line in normalized.split('\n')) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith("```")) {
|
||||
if (inFence) {
|
||||
html.append("<pre><code>").append(code).append("</code></pre>")
|
||||
code.setLength(0)
|
||||
inFence = false
|
||||
} else {
|
||||
closeList()
|
||||
inFence = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (inFence) {
|
||||
if (code.isNotEmpty()) code.append('\n')
|
||||
code.append(escape(line))
|
||||
continue
|
||||
}
|
||||
if (trimmed.isEmpty()) {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
|
||||
val headingMatch = heading.matchEntire(trimmed)
|
||||
if (headingMatch != null) {
|
||||
closeList()
|
||||
val level = headingMatch.groupValues[1].length
|
||||
html.append("<h$level>").append(inline(headingMatch.groupValues[2])).append("</h$level>")
|
||||
continue
|
||||
}
|
||||
val unorderedMatch = unordered.matchEntire(trimmed)
|
||||
if (unorderedMatch != null) {
|
||||
openList("ul")
|
||||
html.append("<li>").append(inline(unorderedMatch.groupValues[1])).append("</li>")
|
||||
continue
|
||||
}
|
||||
val orderedMatch = ordered.matchEntire(trimmed)
|
||||
if (orderedMatch != null) {
|
||||
openList("ol")
|
||||
html.append("<li>").append(inline(orderedMatch.groupValues[1])).append("</li>")
|
||||
continue
|
||||
}
|
||||
closeList()
|
||||
if (trimmed.startsWith(">")) {
|
||||
html.append("<blockquote>")
|
||||
.append(inline(trimmed.removePrefix(">").trimStart()))
|
||||
.append("</blockquote>")
|
||||
} else {
|
||||
html.append("<p>").append(inline(trimmed)).append("</p>")
|
||||
}
|
||||
}
|
||||
if (inFence) {
|
||||
html.append("<pre><code>").append(code).append("</code></pre>")
|
||||
}
|
||||
closeList()
|
||||
return html.toString()
|
||||
}
|
||||
|
||||
private fun inline(text: String): String {
|
||||
val codes = mutableListOf<String>()
|
||||
var current = escape(text)
|
||||
current = inlineCode.replace(current) { match ->
|
||||
codes += "<code>${match.groupValues[1]}</code>"
|
||||
"%%MDCODE${codes.lastIndex}%%"
|
||||
}
|
||||
current = image.replace(current) { match -> escape(match.groupValues[1]) }
|
||||
current = link.replace(current) { match ->
|
||||
"""<a href="${match.groupValues[2]}">${match.groupValues[1]}</a>"""
|
||||
}
|
||||
current = boldItalic.replace(current) { match -> "<b><i>${match.groupValues[1]}</i></b>" }
|
||||
current = bold.replace(current) { match -> "<b>${match.groupValues[2]}</b>" }
|
||||
current = italic.replace(current) { match -> "<i>${match.groupValues[1]}</i>" }
|
||||
current = italicUnderscore.replace(current) { match -> "<i>${match.groupValues[1]}</i>" }
|
||||
codes.forEachIndexed { index, value ->
|
||||
current = current.replace("%%MDCODE$index%%", value)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun escape(text: String): String =
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
|
||||
import java.util.Locale
|
||||
|
||||
internal object PlaybackFormat {
|
||||
fun status(snapshot: PlaybackSnapshot): String {
|
||||
val state = when {
|
||||
snapshot.isPlaying -> "播放中"
|
||||
snapshot.playbackState.name == "BUFFERING" -> "缓冲中"
|
||||
snapshot.playbackState.name == "ENDED" -> "已结束"
|
||||
else -> "已暂停"
|
||||
}
|
||||
val sentence = snapshot.sentenceIndex?.let { " · 第 ${it + 1} 句" }.orEmpty()
|
||||
return "$state$sentence"
|
||||
}
|
||||
|
||||
fun progress(snapshot: PlaybackSnapshot, max: Int = PROGRESS_MAX): Int {
|
||||
if (snapshot.durationMs <= 0L) {
|
||||
return 0
|
||||
}
|
||||
return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * max) / snapshot.durationMs).toInt()
|
||||
}
|
||||
|
||||
fun seekPosition(progress: Int, durationMs: Long, max: Int = PROGRESS_MAX): Long {
|
||||
if (durationMs <= 0L) {
|
||||
return 0L
|
||||
}
|
||||
return progress.toLong() * durationMs / max
|
||||
}
|
||||
|
||||
fun time(ms: Long): String {
|
||||
if (ms < 0) {
|
||||
return "--:--"
|
||||
}
|
||||
val totalSeconds = ms / 1000
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (hours > 0) {
|
||||
"%d:%02d:%02d".format(Locale.US, hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(Locale.US, minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun speed(speed: Float): String {
|
||||
val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.')
|
||||
return "${value}x"
|
||||
}
|
||||
|
||||
fun isVideoHash(value: String): Boolean = SHA256_PATTERN.matches(value)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
|
||||
/** Holds view references recreated when the sample app rebuilds its content tree. */
|
||||
internal class SampleBindings {
|
||||
lateinit var playerView: OralTrainerPlayerView
|
||||
lateinit var lessonTitleText: TextView
|
||||
lateinit var statusText: TextView
|
||||
lateinit var sentenceText: TextView
|
||||
lateinit var sentenceZhText: TextView
|
||||
lateinit var sentenceMetaText: TextView
|
||||
lateinit var timeText: TextView
|
||||
lateinit var speedText: TextView
|
||||
lateinit var progressBar: SeekBar
|
||||
lateinit var catalogList: LinearLayout
|
||||
lateinit var catalogStatusText: TextView
|
||||
var catalogRefreshButton: Button? = null
|
||||
lateinit var testStatusText: TextView
|
||||
lateinit var scoreSummaryText: TextView
|
||||
lateinit var scoreDetailText: TextView
|
||||
lateinit var recordButton: Button
|
||||
lateinit var playDubButton: Button
|
||||
lateinit var mergeDubButton: Button
|
||||
lateinit var shareDubButton: Button
|
||||
var dubbingStatusText: TextView? = null
|
||||
var explainSentenceButton: ImageButton? = null
|
||||
var myContentList: LinearLayout? = null
|
||||
var favoriteSentenceList: LinearLayout? = null
|
||||
|
||||
val hasPlayerChrome: Boolean
|
||||
get() = ::lessonTitleText.isInitialized &&
|
||||
::statusText.isInitialized &&
|
||||
::sentenceText.isInitialized &&
|
||||
::sentenceZhText.isInitialized &&
|
||||
::sentenceMetaText.isInitialized &&
|
||||
::timeText.isInitialized &&
|
||||
::speedText.isInitialized &&
|
||||
::progressBar.isInitialized
|
||||
|
||||
val hasCatalogChrome: Boolean
|
||||
get() = ::catalogList.isInitialized && ::catalogStatusText.isInitialized
|
||||
|
||||
val hasTestChrome: Boolean
|
||||
get() = ::testStatusText.isInitialized &&
|
||||
::scoreSummaryText.isInitialized &&
|
||||
::scoreDetailText.isInitialized &&
|
||||
::recordButton.isInitialized &&
|
||||
::playDubButton.isInitialized &&
|
||||
::mergeDubButton.isInitialized &&
|
||||
::shareDubButton.isInitialized
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
internal interface ModuleTabActions {
|
||||
fun onModuleSelected(module: Module)
|
||||
}
|
||||
|
||||
internal interface CourseScreenActions {
|
||||
fun onSelectCourse(video: TrainingVideoSummary)
|
||||
fun onRefreshCourses()
|
||||
}
|
||||
|
||||
internal interface SentenceExplainActions {
|
||||
fun onExplainCurrentSentence()
|
||||
}
|
||||
|
||||
internal interface TrainScreenActions :
|
||||
SentenceExplainActions {
|
||||
fun onSeekProgressPreview(progress: Int)
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onContinuousPlaybackChanged(enabled: Boolean)
|
||||
fun onChineseSubtitlesChanged(enabled: Boolean)
|
||||
}
|
||||
|
||||
internal interface ReadingScreenActions {
|
||||
fun onReadingTranslationVisible(visible: Boolean)
|
||||
fun isSentenceFavorite(sentence: SentenceBoundary): Boolean
|
||||
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
|
||||
fun onSentenceDoubleTapped(sentence: SentenceBoundary)
|
||||
}
|
||||
|
||||
internal interface TestScreenActions :
|
||||
SentenceExplainActions {
|
||||
fun onSeekProgressPreview(progress: Int)
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onTestSubtitleChanged(visible: Boolean)
|
||||
fun onPrevSentence()
|
||||
fun onReplaySentence()
|
||||
fun onNextSentence()
|
||||
fun onToggleRecording()
|
||||
fun onToggleDubPlayback()
|
||||
fun onMergeDub()
|
||||
fun onShareDub()
|
||||
}
|
||||
|
||||
internal interface MineScreenActions {
|
||||
fun onLogout()
|
||||
fun nickname(): String
|
||||
fun favoriteSentences(): List<SentenceBoundary>
|
||||
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
|
||||
}
|
||||
|
||||
internal interface SampleScreenActions :
|
||||
ModuleTabActions,
|
||||
CourseScreenActions,
|
||||
TrainScreenActions,
|
||||
ReadingScreenActions,
|
||||
TestScreenActions,
|
||||
MineScreenActions
|
||||
@@ -0,0 +1,113 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import cn.learningpad.oraltrainer.sample.ui.CourseScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.MineScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.PlayerChrome
|
||||
import cn.learningpad.oraltrainer.sample.ui.ReadingScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.SampleWidgets
|
||||
import cn.learningpad.oraltrainer.sample.ui.TestScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.TrainScreen
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
/** Facade that routes content creation to module-specific screens. */
|
||||
internal class SampleScreenFactory(
|
||||
private val activity: Activity,
|
||||
bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
controller: OralTrainerController,
|
||||
private val actions: SampleScreenActions,
|
||||
) {
|
||||
private val widgets = SampleWidgets(activity, screenState)
|
||||
private val playerChrome = PlayerChrome(
|
||||
activity = activity,
|
||||
bindings = bindings,
|
||||
screenState = screenState,
|
||||
controller = controller,
|
||||
widgets = widgets,
|
||||
seekActions = actions,
|
||||
)
|
||||
private val courseScreen = CourseScreen(activity, bindings, screenState, widgets, actions)
|
||||
private val trainScreen = TrainScreen(
|
||||
activity = activity,
|
||||
playerChrome = playerChrome,
|
||||
)
|
||||
private val testScreen = TestScreen(activity, bindings, screenState, playerChrome, actions)
|
||||
private val readingScreen = ReadingScreen(
|
||||
activity = activity,
|
||||
screenState = screenState,
|
||||
controller = controller,
|
||||
widgets = widgets,
|
||||
actions = actions,
|
||||
)
|
||||
private val mineScreen = MineScreen(activity, bindings, screenState, widgets, actions)
|
||||
|
||||
fun createContentView(): View {
|
||||
val isLandscape =
|
||||
activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
if ((screenState.activeModule == Module.TRAIN || screenState.activeModule == Module.TEST) &&
|
||||
isLandscape
|
||||
) {
|
||||
return playerChrome.createLandscapeContentView()
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(widgets.moduleTabBar(actions))
|
||||
when (screenState.activeModule) {
|
||||
Module.COURSE -> addView(courseScreen.createSection())
|
||||
Module.MINE -> addView(mineScreen.createSection())
|
||||
Module.TRAIN -> trainScreen.attachPortraitSections(this)
|
||||
Module.READING -> addView(readingScreen.createSection())
|
||||
Module.TEST -> testScreen.attachSections(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshReadingSentences() {
|
||||
if (screenState.activeModule == Module.READING) {
|
||||
readingScreen.renderSentences()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshReadingTranslations() {
|
||||
if (screenState.activeModule == Module.READING) {
|
||||
readingScreen.refreshTranslations()
|
||||
}
|
||||
}
|
||||
|
||||
fun scrollToReadingCurrentSentence() {
|
||||
if (screenState.activeModule == Module.READING) {
|
||||
readingScreen.scrollToCurrentSentence()
|
||||
}
|
||||
}
|
||||
|
||||
fun renderCatalog() {
|
||||
if (screenState.activeModule == Module.COURSE) {
|
||||
courseScreen.renderCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
fun showCatalogLoading() {
|
||||
if (screenState.activeModule == Module.COURSE) {
|
||||
courseScreen.showLoading()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCatalogStatus(text: String) {
|
||||
courseScreen.setStatus(text)
|
||||
}
|
||||
|
||||
fun refreshMyContent(userApi: UserApi, token: String) {
|
||||
mineScreen.refreshContent(userApi, token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.graphics.Color
|
||||
|
||||
internal object SampleTheme {
|
||||
val background = Color.rgb(12, 15, 18)
|
||||
val surface = Color.rgb(28, 34, 40)
|
||||
val border = Color.rgb(50, 59, 67)
|
||||
val button = Color.rgb(37, 99, 235)
|
||||
val accent = Color.rgb(21, 184, 132)
|
||||
val accentDeep = Color.rgb(7, 118, 86)
|
||||
val accentLight = Color.rgb(94, 234, 182)
|
||||
val selected = Color.rgb(229, 248, 240)
|
||||
val progressTrack = Color.rgb(224, 231, 235)
|
||||
val progressTrackDark = Color.rgb(55, 62, 70)
|
||||
val lightBorder = Color.rgb(218, 226, 232)
|
||||
val overlay = Color.argb(178, 9, 12, 16)
|
||||
val overlayFaint = Color.argb(51, 9, 12, 16)
|
||||
val textDark = Color.rgb(18, 24, 31)
|
||||
val textMuted = Color.rgb(151, 162, 174)
|
||||
val textSubtle = Color.rgb(88, 98, 108)
|
||||
val readingTranslation = Color.rgb(147, 197, 253)
|
||||
val danger = Color.rgb(220, 38, 38)
|
||||
val textOverlay = Color.rgb(163, 172, 182)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.LoopMode
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
|
||||
import cn.learningpad.oraltrainer.sdk.PlayerConfig
|
||||
|
||||
internal data class SdkComponents(
|
||||
val sdk: OralTrainerSdk,
|
||||
val controller: OralTrainerController,
|
||||
)
|
||||
|
||||
internal object SdkModule {
|
||||
fun create(context: Context, userToken: String?): SdkComponents {
|
||||
val sdk = OralTrainerSdk.init(
|
||||
context,
|
||||
OralTrainerSdkConfig(
|
||||
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
|
||||
assessmentApiKey = ASSESSMENT_API_KEY,
|
||||
userToken = userToken?.takeIf { it.isNotBlank() },
|
||||
allowHttpFallback = true,
|
||||
),
|
||||
)
|
||||
val controller = sdk.createController(
|
||||
playerConfig = PlayerConfig(
|
||||
sentenceMode = true,
|
||||
defaultSeekStepMs = 10_000L,
|
||||
autoPlay = false,
|
||||
continuousPlayback = false,
|
||||
),
|
||||
imitationAssessor = sdk.createRemoteImitationQualityAssessor(),
|
||||
).also {
|
||||
it.setLoopMode(LoopMode.ALL)
|
||||
}
|
||||
return SdkComponents(sdk, controller)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class SentenceExplanationResult(
|
||||
val explanation: String,
|
||||
)
|
||||
|
||||
interface SentenceExplanationCallback {
|
||||
fun onSuccess(result: SentenceExplanationResult)
|
||||
fun onError(error: Throwable)
|
||||
}
|
||||
|
||||
class CancellableExplanationRequest internal constructor(
|
||||
private val future: Future<*>,
|
||||
) {
|
||||
fun cancel() {
|
||||
future.cancel(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the app server; AI credentials never live in the mobile app. */
|
||||
internal class SentenceExplanationApi(
|
||||
context: Context,
|
||||
private val baseUrl: String,
|
||||
private val tokenProvider: () -> String,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
|
||||
fun explain(
|
||||
sentenceText: String,
|
||||
courseTitle: String,
|
||||
sentenceIndex: Int?,
|
||||
language: String?,
|
||||
callback: SentenceExplanationCallback,
|
||||
): CancellableExplanationRequest {
|
||||
val future = executor.submit {
|
||||
try {
|
||||
val result = requestBlocking(
|
||||
sentenceText,
|
||||
courseTitle,
|
||||
sentenceIndex,
|
||||
language,
|
||||
)
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onSuccess(result) }
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onError(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return CancellableExplanationRequest(future)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun requestBlocking(
|
||||
sentenceText: String,
|
||||
courseTitle: String,
|
||||
sentenceIndex: Int?,
|
||||
language: String?,
|
||||
): SentenceExplanationResult {
|
||||
val endpoint = baseUrl.trimEnd('/') + "/api/v1/sentences/explain"
|
||||
val body = JSONObject().apply {
|
||||
put("sentence_text", sentenceText)
|
||||
put("course_title", courseTitle)
|
||||
sentenceIndex?.let { put("sentence_index", it) }
|
||||
language?.takeIf { it.isNotBlank() }?.let { put("language", it) }
|
||||
}
|
||||
val connection = URL(endpoint).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.doOutput = true
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.5 (Android)")
|
||||
tokenProvider().takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
connection.outputStream.use { output ->
|
||||
output.write(body.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw IOException("AI explain API returned HTTP $statusCode: $response")
|
||||
}
|
||||
val explanation = JSONObject(response).getString("explanation")
|
||||
return SentenceExplanationResult(explanation)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
|
||||
/** Modal explanation layer shown after the user taps the subtitle AI button. */
|
||||
internal class SentenceExplanationOverlay(
|
||||
private val activity: Activity,
|
||||
private val api: SentenceExplanationApi,
|
||||
) {
|
||||
private val store = SentenceExplanationStore(activity)
|
||||
private var currentOverlay: View? = null
|
||||
private var currentRequest: CancellableExplanationRequest? = null
|
||||
private var requestGeneration = 0
|
||||
|
||||
fun show(
|
||||
sentence: SentenceBoundary,
|
||||
courseTitle: String,
|
||||
mediaId: String,
|
||||
sentences: List<SentenceBoundary> = emptyList(),
|
||||
) {
|
||||
if (sentences.isNotEmpty()) {
|
||||
store.sync(mediaId, sentences)
|
||||
}
|
||||
close()
|
||||
val generation = ++requestGeneration
|
||||
val root = FrameLayout(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(Color.argb(160, 0, 0, 0))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
val card = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(Color.WHITE, 16f, SampleTheme.lightBorder)
|
||||
setPadding(20.dp, 20.dp, 20.dp, 18.dp)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
Gravity.CENTER,
|
||||
).apply {
|
||||
setMargins(24.dp, 48.dp, 24.dp, 48.dp)
|
||||
}
|
||||
}
|
||||
val title = TextView(activity).apply {
|
||||
text = "句子讲解"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 19f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val saveHint = TextView(activity).apply {
|
||||
text = HINT_UNSAVED
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 12f
|
||||
setPadding(6.dp, 0, 0, 0)
|
||||
}
|
||||
val saveButton = TextView(activity).apply {
|
||||
text = "保存"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
setPadding(12.dp, 6.dp, 12.dp, 6.dp)
|
||||
isEnabled = false
|
||||
alpha = 0.45f
|
||||
}
|
||||
val titleRow = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
addView(title)
|
||||
addView(saveButton)
|
||||
addView(saveHint)
|
||||
}
|
||||
val subtitle = TextView(activity).apply {
|
||||
text = sentence.text?.takeIf { it.isNotBlank() } ?: courseTitle
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 14f
|
||||
setPadding(0, 8.dp, 0, 12.dp)
|
||||
maxLines = 5
|
||||
}
|
||||
val message = TextView(activity).apply {
|
||||
text = "AI 正在讲解这句话…"
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 15f
|
||||
setLineSpacing(3.dp.toFloat(), 1.1f)
|
||||
}
|
||||
val scroll = ScrollView(activity).apply {
|
||||
addView(message)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
val close = TextView(activity).apply {
|
||||
text = "关闭"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
background = rounded(SampleTheme.button, 10f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).apply { topMargin = 16.dp }
|
||||
setOnClickListener { close() }
|
||||
}
|
||||
card.addView(titleRow)
|
||||
card.addView(subtitle)
|
||||
card.addView(scroll)
|
||||
card.addView(close)
|
||||
root.addView(card)
|
||||
|
||||
addToWindow(root)
|
||||
currentOverlay = root
|
||||
|
||||
val cached = store.load(mediaId, sentence.index)
|
||||
if (!cached.isNullOrBlank()) {
|
||||
showMarkdown(message, cached)
|
||||
markSaved(saveButton, saveHint)
|
||||
return
|
||||
}
|
||||
|
||||
var loadedExplanation: String? = null
|
||||
saveButton.setOnClickListener {
|
||||
val explanation = loadedExplanation ?: return@setOnClickListener
|
||||
if (store.save(mediaId, sentence.index, explanation)) {
|
||||
markSaved(saveButton, saveHint)
|
||||
}
|
||||
}
|
||||
currentRequest = api.explain(
|
||||
sentence.text.orEmpty(),
|
||||
courseTitle,
|
||||
sentence.index,
|
||||
sentence.language,
|
||||
object : SentenceExplanationCallback {
|
||||
override fun onSuccess(result: SentenceExplanationResult) {
|
||||
if (generation != requestGeneration) return
|
||||
loadedExplanation = result.explanation
|
||||
showMarkdown(message, result.explanation)
|
||||
saveButton.isEnabled = true
|
||||
saveButton.alpha = 1f
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (generation != requestGeneration) return
|
||||
message.text = "讲解失败:${error.message ?: "请稍后重试"}"
|
||||
message.setTextColor(SampleTheme.danger)
|
||||
saveButton.isEnabled = false
|
||||
saveButton.alpha = 0.45f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
currentRequest?.cancel()
|
||||
currentRequest = null
|
||||
currentOverlay?.let { overlay ->
|
||||
(overlay.parent as? ViewGroup)?.removeView(overlay)
|
||||
}
|
||||
currentOverlay = null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
close()
|
||||
api.release()
|
||||
}
|
||||
|
||||
private fun showMarkdown(message: TextView, explanation: String) {
|
||||
message.setTextColor(SampleTheme.textDark)
|
||||
MarkdownText.set(message, explanation)
|
||||
}
|
||||
|
||||
private fun markSaved(saveButton: TextView, saveHint: TextView) {
|
||||
saveButton.text = "已保存"
|
||||
saveButton.isEnabled = false
|
||||
saveButton.alpha = 0.7f
|
||||
saveHint.text = HINT_SAVED
|
||||
}
|
||||
|
||||
private fun addToWindow(view: View) {
|
||||
(activity.window?.decorView as? ViewGroup)?.addView(view)
|
||||
}
|
||||
|
||||
private val Int.dp: Int
|
||||
get() = (this * activity.resources.displayMetrics.density).toInt()
|
||||
|
||||
private val Float.dp: Int
|
||||
get() = (this * activity.resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun rounded(
|
||||
color: Int,
|
||||
radiusDp: Float,
|
||||
strokeColor: Int? = null,
|
||||
): GradientDrawable = GradientDrawable().apply {
|
||||
setColor(color)
|
||||
cornerRadius = radiusDp.dp.toFloat()
|
||||
strokeColor?.let { setStroke(1.dp, it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HINT_UNSAVED = "(下次直接看本地)"
|
||||
const val HINT_SAVED = "(已保存本地)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/** Persists sentence explanations under app filesDir for reuse without another API call. */
|
||||
internal class SentenceExplanationStore(context: Context) {
|
||||
private val rootDir = File(context.applicationContext.filesDir, "sentence-explanations")
|
||||
|
||||
fun load(mediaId: String, sentenceIndex: Int): String? {
|
||||
val file = fileFor(mediaId, sentenceIndex) ?: return null
|
||||
if (!file.isFile || file.length() <= 0L) return null
|
||||
return file.readText()
|
||||
}
|
||||
|
||||
fun save(mediaId: String, sentenceIndex: Int, explanation: String): Boolean {
|
||||
val text = explanation.trim()
|
||||
if (text.isEmpty()) return false
|
||||
val file = fileFor(mediaId, sentenceIndex) ?: return false
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(text)
|
||||
return file.isFile && file.length() > 0L
|
||||
}
|
||||
|
||||
fun sync(mediaId: String, sentences: List<SentenceBoundary>) {
|
||||
if (sentences.isEmpty()) return
|
||||
val directoryName = sanitize(mediaId) ?: return
|
||||
val dir = File(rootDir, directoryName)
|
||||
val revision = revisionFor(sentences)
|
||||
val marker = File(dir, REVISION_FILE)
|
||||
val previous = marker.takeIf { it.isFile }?.readText()?.trim().orEmpty()
|
||||
if (previous == revision) return
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
dir.mkdirs()
|
||||
marker.writeText(revision)
|
||||
}
|
||||
|
||||
private fun fileFor(mediaId: String, sentenceIndex: Int): File? {
|
||||
val directoryName = sanitize(mediaId) ?: return null
|
||||
return File(File(rootDir, directoryName), "$sentenceIndex.txt")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REVISION_FILE = "revision.txt"
|
||||
|
||||
fun revisionFor(sentences: List<SentenceBoundary>): String =
|
||||
sentences.joinToString("\n") { sentence ->
|
||||
"${sentence.index}\t${sentence.text.orEmpty().trim()}"
|
||||
}
|
||||
|
||||
fun sanitize(mediaId: String): String? {
|
||||
val filtered = mediaId.trim().lowercase(Locale.US)
|
||||
.replace(Regex("[^a-z0-9._-]"), "_")
|
||||
.trim('_')
|
||||
return filtered.takeIf { it.isNotBlank() && it != "." && it != ".." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Persists reading-mode favorites locally, scoped by signed-in user and video. */
|
||||
internal class SentenceFavoriteStore(context: Context) {
|
||||
private val preferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun toggle(userKey: String, mediaId: String, sentenceIndex: Int): Boolean {
|
||||
val key = storageKey(userKey, mediaId) ?: return false
|
||||
val favorites = readIndexes(key)
|
||||
val isFavorite = sentenceIndex in favorites
|
||||
val updated = if (isFavorite) {
|
||||
favorites - sentenceIndex
|
||||
} else {
|
||||
favorites + sentenceIndex
|
||||
}
|
||||
preferences.edit()
|
||||
.putString(key, updated.sorted().joinToString(SEPARATOR))
|
||||
.apply()
|
||||
return !isFavorite
|
||||
}
|
||||
|
||||
fun indexes(userKey: String, mediaId: String): Set<Int> {
|
||||
return storageKey(userKey, mediaId)?.let(::readIndexes).orEmpty()
|
||||
}
|
||||
|
||||
private fun readIndexes(key: String): Set<Int> {
|
||||
return preferences.getString(key, null).orEmpty()
|
||||
.split(SEPARATOR)
|
||||
.mapNotNull { it.trim().toIntOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun storageKey(userKey: String, mediaId: String): String? {
|
||||
val normalizedUser = userKey.trim()
|
||||
val normalizedMedia = mediaId.trim()
|
||||
if (normalizedUser.isEmpty() || normalizedMedia.isEmpty()) return null
|
||||
return "$KEY_PREFIX$normalizedUser:$normalizedMedia"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES_NAME = "sentence_reading_favorites"
|
||||
private const val KEY_PREFIX = "indexes"
|
||||
private const val SEPARATOR = ","
|
||||
|
||||
fun userKey(token: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(token.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }.take(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class SubtitleTranslationResult(
|
||||
val videoHash: String,
|
||||
val translations: Map<Int, String>,
|
||||
val complete: Boolean = false,
|
||||
val status: String = "",
|
||||
val error: String = "",
|
||||
)
|
||||
|
||||
interface SubtitleTranslationCallback {
|
||||
fun onProgress(result: SubtitleTranslationResult) {}
|
||||
fun onSuccess(result: SubtitleTranslationResult)
|
||||
fun onError(error: Throwable)
|
||||
}
|
||||
|
||||
class CancellableTranslationRequest internal constructor(
|
||||
private val future: Future<*>,
|
||||
) {
|
||||
fun cancel() {
|
||||
future.cancel(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the app server; AI credentials never live in the mobile app. */
|
||||
internal class SubtitleTranslationApi(
|
||||
context: Context,
|
||||
private val baseUrl: String,
|
||||
private val tokenProvider: () -> String,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
|
||||
fun translate(
|
||||
videoHash: String,
|
||||
expectedIndexes: Collection<Int> = emptyList(),
|
||||
callback: SubtitleTranslationCallback,
|
||||
): CancellableTranslationRequest {
|
||||
val future = executor.submit {
|
||||
try {
|
||||
val result = loadBlocking(videoHash, expectedIndexes) { partial ->
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onProgress(partial) }
|
||||
}
|
||||
}
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onSuccess(result) }
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { callback.onError(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return CancellableTranslationRequest(future)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun loadBlocking(
|
||||
videoHash: String,
|
||||
expectedIndexes: Collection<Int>,
|
||||
onProgress: (SubtitleTranslationResult) -> Unit,
|
||||
): SubtitleTranslationResult {
|
||||
val expected = expectedIndexes.toSet()
|
||||
val deadline = SystemClock.elapsedRealtime() + TRANSLATION_DEADLINE_MS
|
||||
var kickJob = true
|
||||
var lastError: Throwable? = null
|
||||
var lastCount = -1
|
||||
var stalledRounds = 0
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
if (Thread.currentThread().isInterrupted) {
|
||||
throw InterruptedException("Subtitle translation cancelled")
|
||||
}
|
||||
val stored = runCatching {
|
||||
requestBlocking(videoHash, method = "GET", readTimeoutMs = 15_000)
|
||||
}.onFailure { lastError = it }.getOrNull()
|
||||
if (stored == null) {
|
||||
kickJob = true
|
||||
} else {
|
||||
onProgress(stored)
|
||||
if (isComplete(stored, expected)) {
|
||||
return stored
|
||||
}
|
||||
if (stored.translations.size > lastCount) {
|
||||
lastCount = stored.translations.size
|
||||
stalledRounds = 0
|
||||
} else {
|
||||
stalledRounds += 1
|
||||
}
|
||||
if (stored.status != "running" && stalledRounds >= 2) {
|
||||
kickJob = true
|
||||
}
|
||||
}
|
||||
if (kickJob) {
|
||||
val posted = runCatching {
|
||||
requestBlocking(videoHash, method = "POST", readTimeoutMs = 30_000)
|
||||
}.onFailure { error ->
|
||||
lastError = error
|
||||
if (error !is SocketTimeoutException) {
|
||||
stalledRounds += 1
|
||||
}
|
||||
}.getOrNull()
|
||||
kickJob = false
|
||||
if (posted != null) {
|
||||
onProgress(posted)
|
||||
if (isComplete(posted, expected)) {
|
||||
return posted
|
||||
}
|
||||
if (posted.status == "error") {
|
||||
lastError = IOException(posted.error.ifBlank { "Subtitle translation failed" })
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
throw lastError ?: IOException("Subtitle translation timed out")
|
||||
}
|
||||
|
||||
private fun isComplete(
|
||||
result: SubtitleTranslationResult,
|
||||
expected: Set<Int>,
|
||||
): Boolean {
|
||||
if (expected.isNotEmpty()) {
|
||||
return expected.all { result.translations.containsKey(it) }
|
||||
}
|
||||
return result.complete
|
||||
}
|
||||
|
||||
private fun requestBlocking(
|
||||
videoHash: String,
|
||||
method: String,
|
||||
readTimeoutMs: Int,
|
||||
): SubtitleTranslationResult {
|
||||
val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation"
|
||||
val connection = URL(endpoint).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = readTimeoutMs
|
||||
connection.doOutput = false
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.5 (Android)")
|
||||
tokenProvider().takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw IOException("Subtitle translation API returned HTTP $statusCode: $response")
|
||||
}
|
||||
val root = JSONObject(response)
|
||||
val items = root.getJSONArray("translations")
|
||||
val translations = buildMap {
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
val zh = item.optString("zh_text")
|
||||
if (zh.isNotBlank()) {
|
||||
put(item.getInt("index"), zh)
|
||||
}
|
||||
}
|
||||
}
|
||||
return SubtitleTranslationResult(
|
||||
videoHash = root.getString("video_hash"),
|
||||
translations = translations,
|
||||
complete = root.optBoolean("complete", false),
|
||||
status = root.optString("status"),
|
||||
error = root.optString("error"),
|
||||
)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_INTERVAL_MS = 1_500L
|
||||
const val TRANSLATION_DEADLINE_MS = 15 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/** Recording, assessment, and dubbing flow for the TEST module. */
|
||||
internal class TestSessionController(
|
||||
private val activity: Activity,
|
||||
private val controller: OralTrainerController,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val attemptRecorder: AttemptRecorder,
|
||||
private val dubbingState: DubbingState,
|
||||
private val dubbingMerger: DubbingMerger,
|
||||
private val dubbingShareApi: DubbingShareApi,
|
||||
private val dubbingPlayback: DubbingPlayback,
|
||||
private val assessmentState: AssessmentState,
|
||||
private val historyStore: DubbingHistoryStore,
|
||||
private val userApi: UserApi,
|
||||
private val tokenProvider: () -> String,
|
||||
private val host: Host,
|
||||
) {
|
||||
interface Host {
|
||||
val bindings: SampleBindings
|
||||
fun setMainStatus(text: String)
|
||||
fun rebuildContent()
|
||||
fun refreshPlayerUi()
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val background = Executors.newSingleThreadExecutor()
|
||||
private val remoteLoadGeneration = AtomicInteger(0)
|
||||
|
||||
private var loadedVideoHash: String? = null
|
||||
private var assessing = false
|
||||
private var sharing = false
|
||||
private var historyStatusMessage: String? = null
|
||||
|
||||
val isDubbingActive: Boolean
|
||||
get() = dubbingPlayback.isActive
|
||||
|
||||
fun onScreenOff() {
|
||||
if (!dubbingPlayback.isActive) {
|
||||
controller.pause()
|
||||
return
|
||||
}
|
||||
dubbingPlayback.stop(controller)
|
||||
refreshTestUi()
|
||||
host.setMainStatus("已停止配音播放")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
remoteLoadGeneration.incrementAndGet()
|
||||
attemptRecorder.discard()
|
||||
dubbingPlayback.release()
|
||||
background.shutdownNow()
|
||||
}
|
||||
|
||||
fun stopDubbingIfNeeded() {
|
||||
if (dubbingPlayback.isActive) {
|
||||
dubbingPlayback.stop(controller)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads local (and if needed remote) history for the active cloud course. */
|
||||
fun ensureHistoryForCurrentCourse(forceRemote: Boolean = false) {
|
||||
val videoHash = currentVideoHash()
|
||||
if (videoHash == null) {
|
||||
if (loadedVideoHash != null) {
|
||||
loadedVideoHash = null
|
||||
dubbingState.clearMemory()
|
||||
assessmentState.clear()
|
||||
historyStatusMessage = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (loadedVideoHash == videoHash && !forceRemote) {
|
||||
return
|
||||
}
|
||||
loadLocalHistory(videoHash)
|
||||
if (forceRemote || dubbingState.segments.isEmpty()) {
|
||||
restoreRemoteHistory(videoHash)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTestUi() {
|
||||
ensureHistoryForCurrentCourse()
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
applyTestSubtitleVisibility()
|
||||
ui.recordButton.text = if (attemptRecorder.isRecording) "停止并评测" else "开始录音"
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
val currentRecording = sentence?.let { dubbingState.segments[it.index] }
|
||||
val segmentLabel = when {
|
||||
currentRecording != null -> "本句已有配音,重录将覆盖"
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (!assessing && !attemptRecorder.isRecording) {
|
||||
val score = sentence?.let { assessmentState.get(it.index) }
|
||||
if (score != null) {
|
||||
applyAssessmentResult(score, segmentLabel)
|
||||
} else {
|
||||
ui.testStatusText.text = when {
|
||||
item == null || sentence == null -> "请先在训练模块选择课程"
|
||||
segmentLabel != null -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句) · $segmentLabel"
|
||||
else -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
|
||||
}
|
||||
ui.scoreSummaryText.text = "尚未评测"
|
||||
ui.scoreDetailText.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
refreshDubButtons()
|
||||
if (!sharing) {
|
||||
val historyNote = historyStatusMessage
|
||||
if (dubbingState.mergedFile?.exists() == true) {
|
||||
ui.dubbingStatusText?.text = historyNote
|
||||
?: "已合成 ${dubbingState.segments.size} 句配音,可点击“分享配音”"
|
||||
} else {
|
||||
ui.dubbingStatusText?.text = historyNote ?: when (dubbingState.segments.size) {
|
||||
0 -> "配音:请先录音(进入本页会自动加载历史配音)"
|
||||
1 -> "配音:已保存 1 句,可生成配音"
|
||||
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||
}
|
||||
}
|
||||
ui.dubbingStatusText?.setTextColor(SampleTheme.accentDeep)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (!attemptRecorder.isRecording) {
|
||||
startRecording()
|
||||
} else {
|
||||
stopRecordingAndAssess()
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
stopDubbingIfNeeded()
|
||||
controller.pause()
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
activity.requestPermissions(
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
RECORD_AUDIO_REQUEST,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
attemptRecorder.start(activity.cacheDir)
|
||||
val sentence = controller.currentSentence()
|
||||
if (sentence != null) {
|
||||
controller.seekTo(sentence.startMs)
|
||||
controller.setVolume(0f)
|
||||
}
|
||||
controller.play()
|
||||
ui.recordButton.text = "停止并保存"
|
||||
ui.testStatusText.text = if (sentence == null) {
|
||||
"正在录音…读完当前句子后点击“停止并保存”"
|
||||
} else {
|
||||
"正在录音…请对照静音视频口形,读完点击“停止并保存”"
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
ui.testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
||||
attemptRecorder.release()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDubPlayback() {
|
||||
if (dubbingPlayback.isActive) {
|
||||
dubbingPlayback.stop(controller)
|
||||
refreshTestUi()
|
||||
host.setMainStatus("已停止配音播放")
|
||||
return
|
||||
}
|
||||
val sentence = controller.currentSentence()
|
||||
if (sentence == null) {
|
||||
host.setMainStatus("请先选择要播放的句子")
|
||||
return
|
||||
}
|
||||
val audio = dubbingState.segments[sentence.index]
|
||||
if (audio == null || !audio.exists()) {
|
||||
host.setMainStatus("当前句还没有录音,请先录音后再播放")
|
||||
refreshTestUi()
|
||||
return
|
||||
}
|
||||
startDubPlayback(audio, sentence.startMs, sentence.index)
|
||||
}
|
||||
|
||||
fun mergeDubSegments() {
|
||||
stopDubbingIfNeeded()
|
||||
val videoHash = currentVideoHash()
|
||||
val segments = dubbingState.segments.toSortedMap().map { (index, file) -> index to file }
|
||||
if (segments.isEmpty()) {
|
||||
setDubbingStatus("没有可合成的配音")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val output = dubbingMerger.merge(segments)
|
||||
val persisted = if (videoHash != null) {
|
||||
historyStore.saveMerged(videoHash, output).also {
|
||||
if (it.absolutePath != output.absolutePath) {
|
||||
output.delete()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output
|
||||
}
|
||||
dubbingState.setMergedFile(persisted)
|
||||
historyStatusMessage = null
|
||||
refreshTestUi()
|
||||
setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "merge failed", error)
|
||||
setDubbingStatus(
|
||||
"生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}" +
|
||||
" · 已保存 ${dubbingState.segments.size} 句",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun shareMergedDubbing() {
|
||||
stopDubbingIfNeeded()
|
||||
if (sharing) {
|
||||
return
|
||||
}
|
||||
val item = controller.currentTrainingItem()
|
||||
if (item == null || !PlaybackFormat.isVideoHash(item.id)) {
|
||||
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
|
||||
return
|
||||
}
|
||||
val token = tokenProvider().trim()
|
||||
if (token.isEmpty()) {
|
||||
setDubbingStatus("分享失败:请先登录后再分享配音")
|
||||
return
|
||||
}
|
||||
val segmentsSnapshot = dubbingState.segments.toMap()
|
||||
val scoresSnapshot = assessmentState.toMap()
|
||||
val videoHash = item.id
|
||||
val videoTitle = item.title
|
||||
sharing = true
|
||||
setDubbingStatus("正在生成分享链接,请稍候...", alert = true)
|
||||
refreshDubButtons()
|
||||
background.execute {
|
||||
try {
|
||||
val shareUrl = dubbingShareApi.upload(
|
||||
baseUrl = SERVER_BASE_URL,
|
||||
token = token,
|
||||
videoHash = videoHash,
|
||||
videoTitle = videoTitle,
|
||||
segments = segmentsSnapshot,
|
||||
scores = scoresSnapshot,
|
||||
)
|
||||
mainHandler.post {
|
||||
sharing = false
|
||||
if (!host.bindings.hasTestChrome) {
|
||||
return@post
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
|
||||
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
activity.startActivity(Intent.createChooser(intent, "分享配音"))
|
||||
setDubbingStatus("已生成分享链接:$shareUrl")
|
||||
refreshDubButtons()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "share failed", error)
|
||||
val detail = error.message?.takeIf { it.isNotBlank() }
|
||||
?: error.javaClass.simpleName
|
||||
mainHandler.post {
|
||||
sharing = false
|
||||
setDubbingStatus("分享失败:$detail", alert = true)
|
||||
refreshDubButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onTestSubtitleChanged(visible: Boolean) {
|
||||
screenState.testSubtitleVisible = visible
|
||||
screenState.landscapeSubtitleVisible = visible
|
||||
host.rebuildContent()
|
||||
host.refreshPlayerUi()
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun loadLocalHistory(videoHash: String) {
|
||||
val snapshot = historyStore.loadSnapshot(videoHash)
|
||||
dubbingState.replaceAll(snapshot.segments, snapshot.mergedFile)
|
||||
assessmentState.replaceAll(snapshot.scores)
|
||||
loadedVideoHash = videoHash
|
||||
historyStatusMessage = when {
|
||||
snapshot.segments.isNotEmpty() && snapshot.scores.isNotEmpty() ->
|
||||
"已加载历史配音 ${snapshot.segments.size} 句、评分 ${snapshot.scores.size} 句"
|
||||
snapshot.segments.isNotEmpty() ->
|
||||
"已加载历史配音 ${snapshot.segments.size} 句"
|
||||
snapshot.scores.isNotEmpty() ->
|
||||
"已加载历史评分 ${snapshot.scores.size} 句(暂无本地录音)"
|
||||
else -> null
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"loaded local history video=$videoHash segments=${snapshot.segments.size} scores=${snapshot.scores.size}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreRemoteHistory(videoHash: String) {
|
||||
val token = tokenProvider().trim()
|
||||
if (token.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val generation = remoteLoadGeneration.incrementAndGet()
|
||||
historyStatusMessage = when {
|
||||
dubbingState.segments.isEmpty() && assessmentState.size == 0 -> "正在同步历史配音…"
|
||||
else -> historyStatusMessage
|
||||
}
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.dubbingStatusText?.text = historyStatusMessage
|
||||
}
|
||||
|
||||
// Latest score summary per sentence.
|
||||
userApi.results(token) { result ->
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@results
|
||||
}
|
||||
result.onSuccess { rows ->
|
||||
val latestBySentence = linkedMapOf<Int, cn.learningpad.oraltrainer.sdk.UserResult>()
|
||||
rows.filter { it.videoHash.equals(videoHash, ignoreCase = true) }
|
||||
.sortedBy { it.createdAt }
|
||||
.forEach { latestBySentence[it.sentenceIndex] = it }
|
||||
var added = 0
|
||||
latestBySentence.forEach { (index, row) ->
|
||||
if (assessmentState.get(index) == null) {
|
||||
assessmentState.store(
|
||||
index,
|
||||
ImitationAssessmentResult(
|
||||
overallScore = row.overallScore,
|
||||
passed = row.passed,
|
||||
referenceText = row.sentenceText.takeIf { it.isNotBlank() },
|
||||
feedback = "来自历史测试记录",
|
||||
),
|
||||
)
|
||||
added += 1
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
historyStore.saveScores(videoHash, assessmentState.toMap())
|
||||
historyStatusMessage = "已同步历史评分 ${assessmentState.size} 句"
|
||||
refreshTestUi()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Latest shared dubbing audio for this course.
|
||||
userApi.dubShares(token) { shareResult ->
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@dubShares
|
||||
}
|
||||
val latestShare = shareResult.getOrNull()
|
||||
?.filter { it.videoHash.equals(videoHash, ignoreCase = true) }
|
||||
?.maxByOrNull { it.createdAt }
|
||||
if (latestShare == null) {
|
||||
if (dubbingState.segments.isEmpty() && assessmentState.size == 0) {
|
||||
historyStatusMessage = "暂无历史配音,请开始录音"
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.dubbingStatusText?.text = historyStatusMessage
|
||||
}
|
||||
}
|
||||
return@dubShares
|
||||
}
|
||||
if (dubbingState.segments.isNotEmpty()) {
|
||||
return@dubShares
|
||||
}
|
||||
userApi.dubShareDetail(latestShare.shareId) { detailResult ->
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@dubShareDetail
|
||||
}
|
||||
val detail = detailResult.getOrNull() ?: run {
|
||||
Log.w(TAG, "dub share detail failed: ${detailResult.exceptionOrNull()?.message}")
|
||||
return@dubShareDetail
|
||||
}
|
||||
background.execute {
|
||||
val downloaded = linkedMapOf<Int, File>()
|
||||
val remoteScores = linkedMapOf<Int, ImitationAssessmentResult>()
|
||||
detail.segments.forEach { segment ->
|
||||
val file = historyStore.downloadSegmentAudio(
|
||||
baseUrl = SERVER_BASE_URL,
|
||||
videoHash = videoHash,
|
||||
sentenceIndex = segment.index,
|
||||
audioUrl = segment.audioUrl,
|
||||
)
|
||||
if (file != null) {
|
||||
downloaded[segment.index] = file
|
||||
}
|
||||
if (assessmentState.get(segment.index) == null) {
|
||||
remoteScores[segment.index] = ImitationAssessmentResult(
|
||||
overallScore = segment.overallScore,
|
||||
contentScore = segment.scoreDetails["content_score"],
|
||||
fluencyScore = segment.scoreDetails["fluency_score"],
|
||||
durationScore = segment.scoreDetails["duration_score"],
|
||||
pauseScore = segment.scoreDetails["pause_score"],
|
||||
speechRateScore = segment.scoreDetails["speech_rate_score"],
|
||||
recognizedText = segment.recognizedText.takeIf { it.isNotBlank() },
|
||||
feedback = "来自历史配音分享",
|
||||
)
|
||||
}
|
||||
}
|
||||
mainHandler.post {
|
||||
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||
return@post
|
||||
}
|
||||
if (downloaded.isNotEmpty()) {
|
||||
val mergedSegments = dubbingState.segments.toMutableMap()
|
||||
mergedSegments.putAll(downloaded)
|
||||
dubbingState.replaceAll(mergedSegments, dubbingState.mergedFile)
|
||||
}
|
||||
if (remoteScores.isNotEmpty()) {
|
||||
val mergedScores = assessmentState.toMap().toMutableMap()
|
||||
remoteScores.forEach { (index, score) ->
|
||||
if (mergedScores[index] == null) {
|
||||
mergedScores[index] = score
|
||||
}
|
||||
}
|
||||
assessmentState.replaceAll(mergedScores)
|
||||
historyStore.saveScores(videoHash, assessmentState.toMap())
|
||||
}
|
||||
historyStatusMessage = when {
|
||||
downloaded.isNotEmpty() ->
|
||||
"已从云端恢复配音 ${downloaded.size} 句"
|
||||
assessmentState.size > 0 ->
|
||||
"已同步历史评分 ${assessmentState.size} 句"
|
||||
else -> "暂无可用的历史配音音频"
|
||||
}
|
||||
refreshTestUi()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingAndAssess() {
|
||||
if (!attemptRecorder.isRecording) return
|
||||
val ui = host.bindings
|
||||
ui.recordButton.text = "重新录音"
|
||||
val file = try {
|
||||
attemptRecorder.stop()
|
||||
} catch (error: Throwable) {
|
||||
ui.testStatusText.text = "录音太短或无法保存:${error.message.orEmpty()}"
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
controller.setVolume(1f)
|
||||
val sentenceIndex = controller.currentSentence()?.index
|
||||
if (sentenceIndex == null) {
|
||||
ui.testStatusText.text = "没有可归属的句子,本次录音已丢弃"
|
||||
file.delete()
|
||||
attemptRecorder.release()
|
||||
refreshTestUi()
|
||||
return
|
||||
}
|
||||
val videoHash = currentVideoHash()
|
||||
val persisted = if (videoHash != null) {
|
||||
try {
|
||||
historyStore.saveSegment(videoHash, sentenceIndex, file).also {
|
||||
if (it.absolutePath != file.absolutePath) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "persist segment failed", error)
|
||||
file
|
||||
}
|
||||
} else {
|
||||
file
|
||||
}
|
||||
dubbingState.storeSegment(sentenceIndex, persisted)
|
||||
historyStatusMessage = null
|
||||
submitAssessment(Uri.fromFile(persisted), sentenceIndex)
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun submitAssessment(recordingUri: Uri, sentenceIndex: Int) {
|
||||
val ui = host.bindings
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
if (item == null || sentence == null) {
|
||||
ui.testStatusText.text = "没有正在学习的句子,无法评测"
|
||||
return
|
||||
}
|
||||
if (!PlaybackFormat.isVideoHash(item.id)) {
|
||||
ui.testStatusText.text = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
|
||||
return
|
||||
}
|
||||
if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) {
|
||||
ui.testStatusText.text = "未配置评分密钥:请在 AppConfig 填入服务器 .env 的 CLIENT_API_KEY"
|
||||
return
|
||||
}
|
||||
assessing = true
|
||||
ui.testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
|
||||
ui.scoreSummaryText.text = "评测中…"
|
||||
ui.scoreDetailText.text = ""
|
||||
controller.assessCurrentSentence(
|
||||
recordingUri = recordingUri,
|
||||
locale = sentence.language
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: item.sentences.getOrNull(sentence.index)?.language,
|
||||
metadata = mapOf("video_hash" to item.id),
|
||||
callback = object : ImitationAssessmentCallback {
|
||||
override fun onResult(result: ImitationAssessmentResult) {
|
||||
assessing = false
|
||||
assessmentState.store(sentenceIndex, result)
|
||||
currentVideoHash()?.let { hash ->
|
||||
historyStore.saveScores(hash, assessmentState.toMap())
|
||||
}
|
||||
if (host.bindings.hasTestChrome) {
|
||||
applyAssessmentResult(result)
|
||||
refreshDubButtons()
|
||||
host.bindings.dubbingStatusText?.text = when (dubbingState.segments.size) {
|
||||
0 -> "配音:请先录音"
|
||||
1 -> "配音:已保存 1 句,可生成配音"
|
||||
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
assessing = false
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.testStatusText.text = "评测失败:${error.message.orEmpty()}"
|
||||
host.bindings.scoreSummaryText.text = "评测失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyAssessmentResult(
|
||||
result: ImitationAssessmentResult,
|
||||
segmentLabel: String? = null,
|
||||
) {
|
||||
val ui = host.bindings
|
||||
val passLabel = when (result.passed) {
|
||||
true -> "已通过"
|
||||
false -> "未通过"
|
||||
null -> "已评测"
|
||||
}
|
||||
ui.testStatusText.text = listOfNotNull(passLabel, segmentLabel).joinToString(" · ")
|
||||
ui.scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
|
||||
ui.scoreDetailText.text = buildString {
|
||||
result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) }
|
||||
result.fluencyScore?.let { appendLine(String.format(Locale.US, "流畅度 %.1f", it)) }
|
||||
result.durationScore?.let { appendLine(String.format(Locale.US, "时长分 %.1f", it)) }
|
||||
result.pauseScore?.let { appendLine(String.format(Locale.US, "停顿分 %.1f", it)) }
|
||||
result.speechRateScore?.let { appendLine(String.format(Locale.US, "语速分 %.1f", it)) }
|
||||
result.referenceText?.let { appendLine("参考:$it") }
|
||||
result.recognizedText?.let { appendLine("识别:$it") }
|
||||
if (result.missingTokens.isNotEmpty()) {
|
||||
appendLine("漏读:${result.missingTokens.joinToString("、")}")
|
||||
}
|
||||
if (result.extraTokens.isNotEmpty()) {
|
||||
appendLine("多读:${result.extraTokens.joinToString("、")}")
|
||||
}
|
||||
result.feedback?.let { appendLine(it) }
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
private fun applyTestSubtitleVisibility() {
|
||||
if (screenState.activeModule != Module.TEST) {
|
||||
return
|
||||
}
|
||||
screenState.landscapeSubtitleVisible = screenState.testSubtitleVisible
|
||||
val ui = host.bindings
|
||||
if (!ui.hasPlayerChrome) {
|
||||
return
|
||||
}
|
||||
val visibility = if (screenState.testSubtitleVisible) View.VISIBLE else View.GONE
|
||||
ui.sentenceMetaText.visibility = visibility
|
||||
ui.sentenceText.visibility = visibility
|
||||
ui.progressBar.visibility = visibility
|
||||
}
|
||||
|
||||
private fun refreshDubButtons() {
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
val dubbingActive = dubbingPlayback.isActive
|
||||
val currentSentenceAudio = controller.currentSentence()?.let { dubbingState.segments[it.index] }
|
||||
// Keep the play button tappable while active so the user can stop playback.
|
||||
ui.playDubButton.isEnabled = dubbingActive || currentSentenceAudio != null
|
||||
ui.mergeDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
||||
ui.shareDubButton.isEnabled =
|
||||
dubbingState.mergedFile?.exists() == true && !dubbingActive && !sharing
|
||||
ui.playDubButton.text = when {
|
||||
dubbingActive -> "停止播放"
|
||||
currentSentenceAudio != null -> "播放本句录音"
|
||||
dubbingState.segments.isNotEmpty() -> "播放本句录音"
|
||||
else -> "播放录音"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDubPlayback(audioFile: File, sentenceStartMs: Long, sentenceIndex: Int) {
|
||||
dubbingPlayback.start(
|
||||
audioFile = audioFile,
|
||||
controller = controller,
|
||||
sentenceStartMs = sentenceStartMs,
|
||||
sentenceIndex = sentenceIndex,
|
||||
) {
|
||||
refreshTestUi()
|
||||
host.setMainStatus("本句录音播放完成")
|
||||
}
|
||||
refreshTestUi()
|
||||
host.setMainStatus("正在播放当前句录音,原视频画面同步但原声静音")
|
||||
}
|
||||
|
||||
private fun setDubbingStatus(message: String, alert: Boolean = false) {
|
||||
historyStatusMessage = null
|
||||
host.bindings.dubbingStatusText?.apply {
|
||||
text = message
|
||||
setTextColor(if (alert) SampleTheme.danger else SampleTheme.accentDeep)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentVideoHash(): String? {
|
||||
val id = controller.currentTrainingItem()?.id
|
||||
?: screenState.currentCourse?.videoHash
|
||||
?: screenState.activeItemId
|
||||
return DubbingHistoryStore.normalize(id.orEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "Dubbing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
|
||||
/** Keeps the training player alive after the screen turns off or the app backgrounds. */
|
||||
class TrainingPlaybackService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.training_playback_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.training_playback_notification_title))
|
||||
.setContentText(getString(R.string.training_playback_notification_text))
|
||||
.setSmallIcon(R.drawable.ic_notification_play)
|
||||
.setContentIntent(contentIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "training_playback"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, TrainingPlaybackService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, TrainingPlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
internal class TrainingScreenState {
|
||||
var activeItemId = ""
|
||||
var currentSentenceCount = 0
|
||||
var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||
val courseEnrollment = mutableMapOf<String, Boolean>()
|
||||
val courseBoundaries = mutableMapOf<String, SentenceBoundaryApiResult>()
|
||||
var catalogStatus = "正在同步"
|
||||
var catalogIsRefreshing = false
|
||||
var activeModule = Module.COURSE
|
||||
var currentCourse: TrainingVideoSummary? = null
|
||||
var continuousPlaybackEnabled = false
|
||||
var chineseSubtitlesEnabled = false
|
||||
var readingTranslationVisible = true
|
||||
var chineseTranslationLoading = false
|
||||
var chineseTranslationError = ""
|
||||
var testSubtitleVisible = false
|
||||
var landscapeSubtitleVisible = true
|
||||
|
||||
private var boundaryRequestGeneration = 0
|
||||
private var catalogRequestGeneration = 0
|
||||
private var translationRequestGeneration = 0
|
||||
|
||||
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
|
||||
|
||||
fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean =
|
||||
boundaryRequestGeneration == generation && activeItemId == itemId
|
||||
|
||||
fun beginCatalogRequest(): Int = ++catalogRequestGeneration
|
||||
|
||||
fun isCurrentCatalogRequest(generation: Int): Boolean =
|
||||
catalogRequestGeneration == generation
|
||||
|
||||
fun beginTranslationRequest(): Int = ++translationRequestGeneration
|
||||
|
||||
fun isCurrentTranslationRequest(generation: Int, itemId: String): Boolean =
|
||||
translationRequestGeneration == generation && activeItemId == itemId
|
||||
|
||||
fun clearCourse() {
|
||||
currentCourse = null
|
||||
activeItemId = ""
|
||||
currentSentenceCount = 0
|
||||
catalogVideos = emptyList()
|
||||
courseEnrollment.clear()
|
||||
courseBoundaries.clear()
|
||||
catalogStatus = "正在同步"
|
||||
catalogIsRefreshing = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.widget.LinearLayout
|
||||
|
||||
/** Shared density/style helpers for Activity and coordinator UI builders. */
|
||||
internal interface UiStyled {
|
||||
val uiContext: Context
|
||||
|
||||
val Int.dp: Int
|
||||
get() = (this * uiContext.resources.displayMetrics.density).toInt()
|
||||
|
||||
val Float.dp: Int
|
||||
get() = (this * uiContext.resources.displayMetrics.density).toInt()
|
||||
|
||||
fun rounded(
|
||||
color: Int,
|
||||
radiusDp: Float,
|
||||
strokeColor: Int? = null,
|
||||
): GradientDrawable = GradientDrawable().apply {
|
||||
setColor(color)
|
||||
cornerRadius = radiusDp.dp.toFloat()
|
||||
strokeColor?.let { setStroke(1.dp, it) }
|
||||
}
|
||||
|
||||
fun LinearLayout.LayoutParams.withMargins(
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
): LinearLayout.LayoutParams = apply {
|
||||
setMargins(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.CourseScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class CourseScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: CourseScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createSection(): View {
|
||||
bindings.catalogStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
textSize = 13f
|
||||
text = screenState.catalogStatus
|
||||
setPadding(16.dp, 0.dp, 16.dp, 8.dp)
|
||||
}
|
||||
bindings.catalogList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 8.dp, 16.dp, 16.dp)
|
||||
}
|
||||
val header = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(16.dp, 0.dp, 16.dp, 0.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "课程首页"
|
||||
textSize = 26f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
bindings.catalogRefreshButton = widgets.headerButton("刷新课程") {
|
||||
actions.onRefreshCourses()
|
||||
}.also { button ->
|
||||
button.isEnabled = !screenState.catalogIsRefreshing
|
||||
button.alpha = if (screenState.catalogIsRefreshing) 0.55f else 1f
|
||||
addView(button)
|
||||
}
|
||||
}
|
||||
return ScrollView(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0.dp, 12.dp, 0.dp, 16.dp)
|
||||
addView(header)
|
||||
addView(TextView(activity).apply {
|
||||
text = "选择一门课程开始训练"
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(16.dp, 4.dp, 16.dp, 8.dp)
|
||||
})
|
||||
addView(bindings.catalogStatusText)
|
||||
addView(bindings.catalogList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun renderCatalog() {
|
||||
if (!bindings.hasCatalogChrome) return
|
||||
bindings.catalogList.removeAllViews()
|
||||
bindings.catalogStatusText.text = screenState.catalogStatus
|
||||
if (screenState.catalogVideos.isEmpty()) {
|
||||
bindings.catalogList.addView(widgets.infoCard("暂无课程"))
|
||||
return
|
||||
}
|
||||
screenState.catalogVideos.forEach { video ->
|
||||
bindings.catalogList.addView(
|
||||
widgets.courseListCard(video) { actions.onSelectCourse(video) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLoading() {
|
||||
if (!bindings.hasCatalogChrome) return
|
||||
bindings.catalogList.removeAllViews()
|
||||
bindings.catalogList.addView(widgets.infoCard("课程加载中..."))
|
||||
}
|
||||
|
||||
fun setStatus(text: String) {
|
||||
if (bindings.hasCatalogChrome) {
|
||||
bindings.catalogStatusText.text = text
|
||||
}
|
||||
bindings.catalogRefreshButton?.let { button ->
|
||||
button.isEnabled = !screenState.catalogIsRefreshing
|
||||
button.alpha = if (screenState.catalogIsRefreshing) 0.55f else 1f
|
||||
button.text = if (screenState.catalogIsRefreshing) "刷新中" else "刷新课程"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sample.MineScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
internal class MineScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: MineScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private val deletingHistoryIds = mutableSetOf<String>()
|
||||
|
||||
fun createSection(): View {
|
||||
bindings.myContentList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
bindings.favoriteSentenceList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
return ScrollView(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = actions.nickname().ifBlank { "我的学习" }
|
||||
textSize = 26f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "退出登录"
|
||||
setOnClickListener { actions.onLogout() }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).withMargins(0, 12.dp, 0, 20.dp)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "本视频收藏句子"
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(bindings.favoriteSentenceList)
|
||||
addView(TextView(activity).apply {
|
||||
text = "我的测试结果"
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(bindings.myContentList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshContent(userApi: UserApi, token: String) {
|
||||
renderFavoriteSentences()
|
||||
val list = bindings.myContentList ?: return
|
||||
list.removeAllViews()
|
||||
val course = screenState.currentCourse
|
||||
if (course == null) {
|
||||
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
|
||||
return
|
||||
}
|
||||
list.addView(courseHeading(course.title))
|
||||
list.addView(widgets.infoCard("加载中..."))
|
||||
userApi.results(token) { result ->
|
||||
list.removeAllViews()
|
||||
list.addView(courseHeading(course.title))
|
||||
result.fold(
|
||||
onSuccess = { rows ->
|
||||
val filtered = rows.filter { it.videoHash == course.videoHash }
|
||||
if (filtered.isEmpty()) list.addView(widgets.infoCard("该课程还没有测试记录"))
|
||||
filtered.forEach { row ->
|
||||
val passedLabel = if (row.passed) "通过" else "未通过"
|
||||
list.addView(
|
||||
historyCard(
|
||||
text = "第 ${row.sentenceIndex + 1} 句" + "\n" +
|
||||
"得分 ${row.overallScore.toInt()} · $passedLabel",
|
||||
id = row.attemptId,
|
||||
) {
|
||||
deleteResult(userApi, token, row.attemptId)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { list.addView(widgets.infoCard("测试记录加载失败:${it.message}")) },
|
||||
)
|
||||
userApi.dubShares(token) { shareResult ->
|
||||
list.post {
|
||||
list.addView(widgets.titleDivider("我的配音分享"))
|
||||
shareResult.fold(
|
||||
onSuccess = { shares ->
|
||||
val filtered = shares.filter { it.videoHash == course.videoHash }
|
||||
if (filtered.isEmpty()) {
|
||||
list.addView(widgets.infoCard("该课程还没有分享配音"))
|
||||
}
|
||||
filtered.forEach { row ->
|
||||
list.addView(
|
||||
historyCard(
|
||||
text = "${row.title}" + "\n" +
|
||||
"${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分",
|
||||
id = row.shareId,
|
||||
) {
|
||||
deleteDubShare(userApi, token, row.shareId)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
list.addView(widgets.infoCard("配音分享加载失败:${it.message}"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFavoriteSentences() {
|
||||
val list = bindings.favoriteSentenceList ?: return
|
||||
list.removeAllViews()
|
||||
if (screenState.currentCourse == null) {
|
||||
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
|
||||
return
|
||||
}
|
||||
val favorites = actions.favoriteSentences()
|
||||
if (favorites.isEmpty()) {
|
||||
list.addView(widgets.infoCard("当前课程还没有收藏句子"))
|
||||
return
|
||||
}
|
||||
favorites.forEach { sentence ->
|
||||
list.addView(favoriteCard(sentence) {
|
||||
if (!actions.toggleSentenceFavorite(sentence)) {
|
||||
renderFavoriteSentences()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun favoriteCard(
|
||||
sentence: SentenceBoundary,
|
||||
onRemove: () -> Unit,
|
||||
): View {
|
||||
val foreign = sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"
|
||||
val translation = sentence.zhText?.takeIf { it.isNotBlank() }
|
||||
val cardText = buildString {
|
||||
append("第 ${sentence.index + 1} 句")
|
||||
append("\n$foreign")
|
||||
translation?.let { append("\n$it") }
|
||||
}
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 10.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = cardText
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "移除"
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(10.dp, 0, 10.dp, 0)
|
||||
background = rounded(SampleTheme.danger, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
32.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener { onRemove() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteResult(userApi: UserApi, token: String, attemptId: String) {
|
||||
userApi.deleteResult(token, attemptId) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
Toast.makeText(activity, "测试记录已删除", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
onFailure = {
|
||||
deletingHistoryIds.remove(attemptId)
|
||||
Toast.makeText(activity, "删除失败:${it.message}", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDubShare(userApi: UserApi, token: String, shareId: String) {
|
||||
userApi.deleteDubShare(token, shareId) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
Toast.makeText(activity, "配音分享已删除", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
onFailure = {
|
||||
deletingHistoryIds.remove(shareId)
|
||||
Toast.makeText(activity, "删除失败:${it.message}", Toast.LENGTH_SHORT).show()
|
||||
refreshContent(userApi, token)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun historyCard(
|
||||
text: String,
|
||||
id: String,
|
||||
onDelete: () -> Unit,
|
||||
): View {
|
||||
val isDeleting = id in deletingHistoryIds
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 10.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
|
||||
addView(TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
setText(if (isDeleting) "删除中..." else "删除")
|
||||
isAllCaps = false
|
||||
isEnabled = !isDeleting
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(10.dp, 0, 10.dp, 0)
|
||||
background = rounded(SampleTheme.danger, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
32.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener {
|
||||
if (!deletingHistoryIds.add(id)) return@setOnClickListener
|
||||
setText("删除中...")
|
||||
isEnabled = false
|
||||
onDelete()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun courseHeading(title: String): TextView =
|
||||
TextView(activity).apply {
|
||||
text = "当前课程:$title"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.graphics.drawable.InsetDrawable
|
||||
import android.graphics.Typeface
|
||||
import android.text.TextUtils
|
||||
import android.view.MotionEvent
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.TranslateAnimation
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import cn.learningpad.oraltrainer.sample.Module
|
||||
import cn.learningpad.oraltrainer.sample.PROGRESS_MAX
|
||||
import cn.learningpad.oraltrainer.sample.R
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
|
||||
/** Player surface, overlays, sentence panel, and landscape subtitle chrome. */
|
||||
internal class PlayerChrome(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val controller: OralTrainerController,
|
||||
private val widgets: SampleWidgets,
|
||||
private val seekActions: TrainScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createPlayerSection(): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(SampleTheme.surface, 8f, SampleTheme.border)
|
||||
clipToOutline = true
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(16.dp, 0, 16.dp, 10.dp)
|
||||
addView(
|
||||
createPlayerOverlay().apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
},
|
||||
)
|
||||
addView(
|
||||
object : FrameLayout(activity) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = (width * 9) / 16
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
addView(createPlayerView())
|
||||
addView(
|
||||
createRotationButton().apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
44.dp,
|
||||
44.dp,
|
||||
Gravity.BOTTOM or Gravity.END,
|
||||
).apply {
|
||||
marginEnd = 12.dp
|
||||
bottomMargin = 12.dp
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createSentenceSection(): View =
|
||||
createSentenceContent(translucent = false).apply {
|
||||
background = rounded(Color.WHITE, 8f, SampleTheme.lightBorder)
|
||||
setPadding(18.dp, 16.dp, 18.dp, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
).withMargins(16.dp, 0, 16.dp, 12.dp)
|
||||
}
|
||||
|
||||
fun createLandscapeContentView(): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
|
||||
val bars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
|
||||
)
|
||||
val gestures = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures())
|
||||
view.setPadding(
|
||||
bars.left,
|
||||
bars.top,
|
||||
bars.right,
|
||||
maxOf(bars.bottom, gestures.bottom, 24.dp),
|
||||
)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
addView(
|
||||
createPlayerOverlay(compact = true).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
},
|
||||
)
|
||||
addView(
|
||||
FrameLayout(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
addView(
|
||||
object : FrameLayout(activity) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val maxHeight = MeasureSpec.getSize(heightMeasureSpec)
|
||||
val heightFromWidth = if (maxWidth > 0) (maxWidth * 9) / 16 else 0
|
||||
val width: Int
|
||||
val height: Int
|
||||
if (heightFromWidth <= maxHeight) {
|
||||
width = maxWidth
|
||||
height = heightFromWidth
|
||||
} else {
|
||||
height = maxHeight
|
||||
width = (maxHeight * 16) / 9
|
||||
}
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.TOP or Gravity.CENTER_HORIZONTAL,
|
||||
)
|
||||
addView(createPlayerView())
|
||||
},
|
||||
)
|
||||
val subtitleContainer = object : LinearLayout(activity) {
|
||||
private var forwardTouchToPlayer = false
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
forwardTouchToPlayer = !isTouchOverInteractiveControl(this, event)
|
||||
}
|
||||
val forwarded = if (forwardTouchToPlayer) {
|
||||
bindings.playerView.forwardGestureFrom(this, event)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val handled = forwarded || super.dispatchTouchEvent(event)
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP ||
|
||||
event.actionMasked == MotionEvent.ACTION_CANCEL
|
||||
) {
|
||||
forwardTouchToPlayer = false
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
}.apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
addView(
|
||||
createLandscapeSentencePanel().apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
addView(subtitleContainer)
|
||||
applyLandscapeSubtitleState(subtitleContainer, animate = false)
|
||||
installLandscapeSwipeGesture(subtitleContainer)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createSentenceContent(translucent: Boolean): LinearLayout {
|
||||
bindings.lessonTitleText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else SampleTheme.textDark)
|
||||
textSize = 17f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 1
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
}
|
||||
bindings.sentenceMetaText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
bindings.sentenceText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else SampleTheme.textDark)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 10.dp, 0, 4.dp)
|
||||
}
|
||||
bindings.sentenceZhText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 16f
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
visibility = View.GONE
|
||||
}
|
||||
bindings.progressBar = SeekBar(activity).apply {
|
||||
max = PROGRESS_MAX
|
||||
progress = 0
|
||||
progressTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.progressTrackDark else SampleTheme.progressTrack,
|
||||
)
|
||||
thumbTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
28.dp,
|
||||
)
|
||||
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) seekActions.onSeekProgressPreview(progress)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
seekActions.onSeekStart()
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
seekActions.onSeekStop(seekBar.progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
bindings.statusText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
if (translucent) {
|
||||
background = rounded(SampleTheme.overlayFaint, 0f, null)
|
||||
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
|
||||
}
|
||||
val topContent = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
if (screenState.activeModule != Module.TRAIN) {
|
||||
addView(bindings.lessonTitleText)
|
||||
}
|
||||
if (screenState.activeModule == Module.TRAIN) {
|
||||
addView(createContinuousPlaybackRow(translucent))
|
||||
addView(createChineseSubtitleRow(translucent))
|
||||
}
|
||||
addView(bindings.sentenceMetaText)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.TOP
|
||||
addView(
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(
|
||||
bindings.sentenceText,
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
addView(
|
||||
bindings.sentenceZhText,
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
},
|
||||
LinearLayout.LayoutParams(
|
||||
0,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
1f,
|
||||
),
|
||||
)
|
||||
bindings.explainSentenceButton = createExplainButton(translucent)
|
||||
addView(
|
||||
bindings.explainSentenceButton,
|
||||
LinearLayout.LayoutParams(32.dp, 32.dp).apply { marginStart = 10.dp },
|
||||
)
|
||||
})
|
||||
}
|
||||
addView(
|
||||
ScrollView(activity).apply {
|
||||
isFillViewport = true
|
||||
isVerticalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(
|
||||
topContent,
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
},
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
if (translucent) ViewGroup.LayoutParams.WRAP_CONTENT else 0,
|
||||
if (translucent) 0f else 1f,
|
||||
),
|
||||
)
|
||||
addView(bindings.progressBar)
|
||||
addView(bindings.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlayerView(): OralTrainerPlayerView =
|
||||
OralTrainerPlayerView(activity).apply {
|
||||
bind(controller)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}.also { bindings.playerView = it }
|
||||
|
||||
private fun createPlayerOverlay(compact: Boolean = false): View {
|
||||
val overlay = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
if (compact) {
|
||||
setPadding(12.dp, 4.dp, 12.dp, 0)
|
||||
} else {
|
||||
setPadding(12.dp, 10.dp, 12.dp, 8.dp)
|
||||
}
|
||||
}
|
||||
bindings.timeText = widgets.overlayPill("--:-- / --:--").apply {
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
bindings.speedText = widgets.overlayPill("1x")
|
||||
overlay.addView(bindings.timeText)
|
||||
overlay.addView(bindings.speedText)
|
||||
return overlay
|
||||
}
|
||||
|
||||
private fun createRotationButton(): ImageButton =
|
||||
ImageButton(activity).apply {
|
||||
setImageResource(R.drawable.ic_screen_rotation)
|
||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
val backgroundSize = 22.dp
|
||||
val buttonSize = 44.dp
|
||||
val inset = (buttonSize - backgroundSize) / 2
|
||||
background = InsetDrawable(
|
||||
GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.argb(102, 9, 12, 16))
|
||||
setSize(backgroundSize, backgroundSize)
|
||||
},
|
||||
inset,
|
||||
)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
setPadding(10.dp, 10.dp, 10.dp, 10.dp)
|
||||
contentDescription = "横屏"
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
clipToOutline = false
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
foreground = null
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(buttonSize, buttonSize)
|
||||
setOnClickListener {
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun createExplainButton(translucent: Boolean): ImageButton =
|
||||
ImageButton(activity).apply {
|
||||
setImageResource(R.drawable.ic_ai_explain)
|
||||
imageTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep,
|
||||
)
|
||||
background = rounded(
|
||||
if (translucent) SampleTheme.overlay else SampleTheme.selected,
|
||||
16.dp.toFloat(),
|
||||
)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
setPadding(6.dp, 6.dp, 6.dp, 6.dp)
|
||||
contentDescription = "AI 讲解这句话"
|
||||
isEnabled = false
|
||||
setOnClickListener { seekActions.onExplainCurrentSentence() }
|
||||
}
|
||||
|
||||
private fun createLandscapeSentencePanel(): View =
|
||||
createSentenceContent(translucent = true).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createContinuousPlaybackRow(translucent: Boolean): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "连续播放"
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.continuousPlaybackEnabled
|
||||
buttonTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accent,
|
||||
)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
seekActions.onContinuousPlaybackChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun createChineseSubtitleRow(translucent: Boolean): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "中文字幕"
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.chineseSubtitlesEnabled
|
||||
buttonTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accent,
|
||||
)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
seekActions.onChineseSubtitlesChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun installLandscapeSwipeGesture(subtitleContainer: View) {
|
||||
bindings.playerView.setVerticalSwipeListener { isSwipeUp ->
|
||||
if (isSwipeUp == screenState.landscapeSubtitleVisible) return@setVerticalSwipeListener
|
||||
screenState.landscapeSubtitleVisible = isSwipeUp
|
||||
applyLandscapeSubtitleState(subtitleContainer, animate = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyLandscapeSubtitleState(container: View, animate: Boolean) {
|
||||
if (!animate || container.visibility == View.GONE) {
|
||||
container.clearAnimation()
|
||||
container.visibility =
|
||||
if (screenState.landscapeSubtitleVisible) View.VISIBLE else View.GONE
|
||||
return
|
||||
}
|
||||
if (screenState.landscapeSubtitleVisible) {
|
||||
container.visibility = View.VISIBLE
|
||||
container.post {
|
||||
if (screenState.landscapeSubtitleVisible && container.visibility == View.VISIBLE) {
|
||||
startSlideAnimation(container, fromYDelta = 1f, toYDelta = 0f) {
|
||||
container.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startSlideAnimation(container, fromYDelta = 0f, toYDelta = 1f) {
|
||||
container.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSlideAnimation(
|
||||
view: View,
|
||||
fromYDelta: Float,
|
||||
toYDelta: Float,
|
||||
onEnd: () -> Unit,
|
||||
) {
|
||||
val animation = TranslateAnimation(0f, 0f, view.height * fromYDelta, view.height * toYDelta).apply {
|
||||
duration = 220L
|
||||
fillAfter = false
|
||||
setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation?) = Unit
|
||||
override fun onAnimationRepeat(animation: Animation?) = Unit
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
view.clearAnimation()
|
||||
onEnd()
|
||||
}
|
||||
})
|
||||
}
|
||||
view.startAnimation(animation)
|
||||
}
|
||||
|
||||
/** Lets controls own their touches while the subtitle background drives player gestures. */
|
||||
private fun isTouchOverInteractiveControl(root: View, event: MotionEvent): Boolean =
|
||||
hasInteractiveTouchTarget(root, event.rawX, event.rawY)
|
||||
|
||||
private fun hasInteractiveTouchTarget(view: View, screenX: Float, screenY: Float): Boolean {
|
||||
if (view.visibility != View.VISIBLE || !view.isEnabled) {
|
||||
return false
|
||||
}
|
||||
if (view.isClickable || view.isLongClickable || view.isContextClickable) {
|
||||
val location = IntArray(2).also(view::getLocationOnScreen)
|
||||
if (screenX >= location[0] &&
|
||||
screenX < location[0] + view.width &&
|
||||
screenY >= location[1] &&
|
||||
screenY < location[1] + view.height
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (view !is ViewGroup) {
|
||||
return false
|
||||
}
|
||||
for (index in view.childCount - 1 downTo 0) {
|
||||
if (hasInteractiveTouchTarget(view.getChildAt(index), screenX, screenY)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.os.SystemClock
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cn.learningpad.oraltrainer.sample.ReadingScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
|
||||
/** Lazy-loading full-text reading view for the selected course. */
|
||||
internal class ReadingScreen(
|
||||
private val activity: Activity,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val controller: OralTrainerController,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: ReadingScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private var readingAdapter: ReadingSentenceAdapter? = null
|
||||
private var titleText: TextView? = null
|
||||
private var sentenceListView: RecyclerView? = null
|
||||
private var lastClickedSentenceIndex = Int.MIN_VALUE
|
||||
private var lastClickedAtMs = 0L
|
||||
|
||||
fun createSection(): View {
|
||||
val root = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||
}
|
||||
val adapter = ReadingSentenceAdapter()
|
||||
readingAdapter = adapter
|
||||
val list = RecyclerView(activity).apply {
|
||||
layoutManager = LinearLayoutManager(activity)
|
||||
this.adapter = adapter
|
||||
itemAnimator = null
|
||||
setItemViewCacheSize(12)
|
||||
clipToPadding = false
|
||||
setPadding(0, 0, 0, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
sentenceListView = list
|
||||
|
||||
val title = TextView(activity).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 0, 0, 6.dp)
|
||||
}
|
||||
titleText = title
|
||||
root.addView(title)
|
||||
root.addView(createTranslationToggle())
|
||||
root.addView(list)
|
||||
renderSentences()
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
fun renderSentences() {
|
||||
val item = controller.currentTrainingItem()
|
||||
val baseTitle = screenState.currentCourse?.title.orEmpty()
|
||||
.ifBlank { item?.title ?: "阅读模式" }
|
||||
val sentences = item?.sentences.orEmpty()
|
||||
titleText?.text = if (sentences.isEmpty()) {
|
||||
baseTitle
|
||||
} else {
|
||||
"$baseTitle · ${sentences.size} 句"
|
||||
}
|
||||
readingAdapter?.submitSentences(sentences)
|
||||
}
|
||||
|
||||
fun refreshTranslations() {
|
||||
readingAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun refreshFavorites() {
|
||||
readingAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun scrollToCurrentSentence() {
|
||||
val list = sentenceListView ?: return
|
||||
val currentSentence = controller.currentSentence() ?: return
|
||||
val item = controller.currentTrainingItem() ?: return
|
||||
val position = item.sentences.indexOfFirst { it.index == currentSentence.index }
|
||||
if (position < 0) return
|
||||
list.post {
|
||||
if (sentenceListView !== list) return@post
|
||||
val layoutManager = list.layoutManager as? LinearLayoutManager ?: return@post
|
||||
val offset = (list.height * 0.25f).toInt().coerceAtLeast(0)
|
||||
layoutManager.scrollToPositionWithOffset(position, offset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTranslationToggle(): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 4.dp, 0, 10.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "中文翻译"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.readingTranslationVisible
|
||||
buttonTintList = android.content.res.ColorStateList.valueOf(SampleTheme.accent)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
actions.onReadingTranslationVisible(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun translationText(sentence: SentenceBoundary): String = when {
|
||||
!sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty()
|
||||
screenState.chineseTranslationLoading -> "正在翻译中文…"
|
||||
screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError
|
||||
else -> "暂无中文翻译"
|
||||
}
|
||||
|
||||
private inner class ReadingSentenceAdapter :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var sentences: List<SentenceBoundary> = emptyList()
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return if (sentences.isEmpty()) EMPTY_ID else sentences[position].index.toLong()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (sentences.isEmpty()) TYPE_EMPTY else TYPE_SENTENCE
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == TYPE_EMPTY) {
|
||||
val empty = TextView(activity).apply {
|
||||
text = "当前课程还没有可阅读的句子文本"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}
|
||||
EmptyViewHolder(empty)
|
||||
} else {
|
||||
SentenceViewHolder(createSentenceView())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (holder !is SentenceViewHolder || sentences.isEmpty()) return
|
||||
val sentence = sentences[position]
|
||||
attachDoubleTap(holder.itemView, sentence)
|
||||
holder.text.text =
|
||||
"${sentence.index + 1}. ${sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"}"
|
||||
holder.translation.visibility = if (screenState.readingTranslationVisible) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
holder.translation.text = translationText(sentence)
|
||||
bindFavoriteButton(holder.favoriteButton, sentence)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return if (sentences.isEmpty()) 1 else sentences.size
|
||||
}
|
||||
|
||||
fun submitSentences(updated: List<SentenceBoundary>) {
|
||||
sentences = updated
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun createSentenceView(): View {
|
||||
val text = TextView(activity).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 16f
|
||||
}
|
||||
val translation = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.readingTranslation)
|
||||
textSize = 14f
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
}
|
||||
val favoriteButton = Button(activity).apply {
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(12.dp, 0, 12.dp, 0)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
32.dp,
|
||||
).withMargins(10.dp, 0, 0, 0)
|
||||
}
|
||||
val textColumn = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
addView(text)
|
||||
addView(translation)
|
||||
}
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = rounded(SampleTheme.surface, 10f, SampleTheme.border)
|
||||
setPadding(14.dp, 12.dp, 10.dp, 12.dp)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
bottomMargin = 10.dp
|
||||
}
|
||||
addView(textColumn)
|
||||
addView(favoriteButton)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindFavoriteButton(button: Button, sentence: SentenceBoundary) {
|
||||
val isFavorite = actions.isSentenceFavorite(sentence)
|
||||
button.setText(if (isFavorite) "已收藏" else "收藏")
|
||||
button.background = rounded(
|
||||
if (isFavorite) SampleTheme.accent else SampleTheme.button,
|
||||
8f,
|
||||
)
|
||||
button.setOnClickListener {
|
||||
val favorite = actions.toggleSentenceFavorite(sentence)
|
||||
button.setText(if (favorite) "已收藏" else "收藏")
|
||||
button.background = rounded(
|
||||
if (favorite) SampleTheme.accent else SampleTheme.button,
|
||||
8f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachDoubleTap(view: View, sentence: SentenceBoundary) {
|
||||
view.isClickable = true
|
||||
view.setOnClickListener {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val isDoubleTap = lastClickedSentenceIndex == sentence.index &&
|
||||
now - lastClickedAtMs <= DOUBLE_TAP_INTERVAL_MS
|
||||
if (isDoubleTap) {
|
||||
lastClickedSentenceIndex = Int.MIN_VALUE
|
||||
lastClickedAtMs = 0L
|
||||
actions.onSentenceDoubleTapped(sentence)
|
||||
} else {
|
||||
lastClickedSentenceIndex = sentence.index
|
||||
lastClickedAtMs = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class EmptyViewHolder(root: View) : RecyclerView.ViewHolder(root)
|
||||
|
||||
private inner class SentenceViewHolder(
|
||||
root: View,
|
||||
) : RecyclerView.ViewHolder(root) {
|
||||
val text: TextView
|
||||
val translation: TextView
|
||||
val favoriteButton: Button
|
||||
|
||||
init {
|
||||
val textColumn = root as ViewGroup
|
||||
val content = textColumn.getChildAt(0) as ViewGroup
|
||||
text = content.getChildAt(0) as TextView
|
||||
translation = content.getChildAt(1) as TextView
|
||||
favoriteButton = textColumn.getChildAt(1) as Button
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TYPE_EMPTY = 0
|
||||
private const val TYPE_SENTENCE = 1
|
||||
private const val EMPTY_ID = Long.MIN_VALUE
|
||||
private const val DOUBLE_TAP_INTERVAL_MS = 300L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sample.Module
|
||||
import cn.learningpad.oraltrainer.sample.ModuleTabActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
/** Shared compact widgets used across module screens. */
|
||||
internal class SampleWidgets(
|
||||
private val activity: Activity,
|
||||
private val screenState: TrainingScreenState,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun moduleTabBar(actions: ModuleTabActions): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(16.dp, 40.dp, 16.dp, 0)
|
||||
addView(moduleTab("课程", Module.COURSE, actions))
|
||||
addView(moduleTab("训练", Module.TRAIN, actions))
|
||||
addView(moduleTab("阅读", Module.READING, actions))
|
||||
addView(moduleTab("测试", Module.TEST, actions))
|
||||
addView(moduleTab("我的", Module.MINE, actions))
|
||||
}
|
||||
|
||||
fun moduleTab(label: String, module: Module, actions: ModuleTabActions): TextView {
|
||||
val selected = screenState.activeModule == module
|
||||
return TextView(activity).apply {
|
||||
text = label
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
isClickable = true
|
||||
setTextColor(if (selected) Color.WHITE else SampleTheme.textMuted)
|
||||
background = if (selected) {
|
||||
rounded(SampleTheme.button, 8f)
|
||||
} else {
|
||||
rounded(SampleTheme.surface, 8f, SampleTheme.border)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener {
|
||||
if (screenState.activeModule == module) return@setOnClickListener
|
||||
if (module != Module.COURSE && screenState.currentCourse == null) {
|
||||
Toast.makeText(activity, "请先选择课程", Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
actions.onModuleSelected(module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun headerButton(label: String, action: () -> Unit): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(14.dp, 0, 14.dp, 0)
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
40.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
|
||||
fun overlayPill(label: String): TextView =
|
||||
TextView(activity).apply {
|
||||
text = label
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
background = rounded(SampleTheme.overlayFaint, 8f)
|
||||
setPadding(10.dp, 6.dp, 10.dp, 6.dp)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
fun infoCard(text: String): TextView =
|
||||
TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
}
|
||||
|
||||
fun titleDivider(text: String): TextView =
|
||||
TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 20.dp, 0, 8.dp)
|
||||
}
|
||||
|
||||
fun courseListCard(
|
||||
video: TrainingVideoSummary,
|
||||
onClick: () -> Unit,
|
||||
): View {
|
||||
val enrolled = screenState.courseEnrollment[video.videoHash] == true
|
||||
val isSelected = screenState.currentCourse?.videoHash == video.videoHash
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
isClickable = true
|
||||
background = if (isSelected) {
|
||||
rounded(SampleTheme.selected, 10f, SampleTheme.accent)
|
||||
} else {
|
||||
rounded(SampleTheme.surface, 10f, SampleTheme.border)
|
||||
}
|
||||
setPadding(16.dp, 14.dp, 16.dp, 14.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 10.dp)
|
||||
setOnClickListener { onClick() }
|
||||
addView(TextView(activity).apply {
|
||||
text = video.title
|
||||
setTextColor(if (isSelected) SampleTheme.textDark else Color.WHITE)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 2
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = buildString {
|
||||
append(if (enrolled) "已选修" else "未选修")
|
||||
append(" · ${video.sentenceCount} 句")
|
||||
if (isSelected) append(" · 当前课程")
|
||||
}
|
||||
setTextColor(if (isSelected) SampleTheme.accentDeep else SampleTheme.textMuted)
|
||||
textSize = 13f
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TestScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class TestScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val playerChrome: PlayerChrome,
|
||||
private val actions: TestScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun attachSections(root: LinearLayout) {
|
||||
root.addView(playerChrome.createPlayerSection())
|
||||
if (screenState.testSubtitleVisible) {
|
||||
root.addView(playerChrome.createSentenceSection())
|
||||
}
|
||||
root.addView(createTestSection())
|
||||
}
|
||||
|
||||
private fun createTestSection(): View {
|
||||
bindings.testStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
bindings.scoreSummaryText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
text = "尚未评测"
|
||||
}
|
||||
bindings.scoreDetailText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
setLineSpacing(4.dp.toFloat(), 1f)
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
bindings.dubbingStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
text = "配音:尚未生成"
|
||||
}
|
||||
|
||||
fun sentenceButton(label: String, action: () -> Unit): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
setPadding(0, 0, 0, 0)
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
|
||||
fun dubButton(label: String): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 8.dp, 8.dp, 0)
|
||||
}
|
||||
|
||||
bindings.recordButton = Button(activity).apply {
|
||||
text = "开始录音"
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(SampleTheme.accent, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
1f,
|
||||
)
|
||||
setOnClickListener { actions.onToggleRecording() }
|
||||
}
|
||||
|
||||
val subtitleToggle = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "显示字幕"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.testSubtitleVisible
|
||||
buttonTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
actions.onTestSubtitleChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
bindings.playDubButton = dubButton("播放配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onToggleDubPlayback() }
|
||||
}
|
||||
bindings.mergeDubButton = dubButton("生成配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onMergeDub() }
|
||||
}
|
||||
bindings.shareDubButton = dubButton("分享配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onShareDub() }
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 0, 0)
|
||||
}
|
||||
|
||||
val controlRow = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, 4.dp, 0, 8.dp)
|
||||
addView(sentenceButton("上一句") { actions.onPrevSentence() })
|
||||
addView(sentenceButton("重播本句") { actions.onReplaySentence() })
|
||||
addView(
|
||||
sentenceButton("下一句") { actions.onNextSentence() }.apply {
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 0, 0, 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(Color.WHITE, 8f, SampleTheme.lightBorder)
|
||||
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(16.dp, 0, 16.dp, 12.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "朗读评测(测试)"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
})
|
||||
addView(subtitleToggle, 0)
|
||||
addView(bindings.testStatusText)
|
||||
addView(controlRow)
|
||||
addView(bindings.recordButton)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(bindings.playDubButton)
|
||||
addView(bindings.mergeDubButton)
|
||||
addView(bindings.shareDubButton)
|
||||
})
|
||||
addView(bindings.scoreSummaryText)
|
||||
addView(bindings.scoreDetailText)
|
||||
addView(bindings.dubbingStatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class TrainScreen(
|
||||
private val activity: Activity,
|
||||
private val playerChrome: PlayerChrome,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun attachPortraitSections(root: LinearLayout) {
|
||||
root.addView(createHeader())
|
||||
root.addView(playerChrome.createPlayerSection())
|
||||
root.addView(playerChrome.createSentenceSection())
|
||||
}
|
||||
|
||||
private fun createHeader(): View {
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(18.dp, 16.dp, 18.dp, 12.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "八哥口语"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
includeFontPadding = false
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "外语跟读训练神器"
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
textSize = 18f
|
||||
includeFontPadding = false
|
||||
setPadding(12.dp, 6.dp, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 131 KiB |
14
android/sample-app/src/main/res/drawable/ic_ai_explain.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M24,4a13,13 0 0 0 -7.2,23.8c0.7,0.5 1.2,1.3 1.2,2.2v1.5h12v-1.5c0,-0.9 0.5,-1.7 1.2,-2.2A13,13 0 0 0 24,4zM18,36h12v3h-12zM20,42h8v2h-8z"/>
|
||||
<path
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M38,10l1.8,4.2L44,16l-4.2,1.8L38,22l-1.8,-4.2L32,16l4.2,-1.8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
android/sample-app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
android/sample-app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/sample-app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
android/sample-app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/sample-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
7
android/sample-app/src/main/res/values-zh/strings.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
6
android/sample-app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="dubbing"
|
||||
path="." />
|
||||
</paths>
|
||||
@@ -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>
|
||||
257
docs/wechat-share-app-name.md
Normal 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()`。
|
||||
487
ios/OralTrainer.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
ios/OralTrainer.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
46
ios/OralTrainer/AppDelegate.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ios/OralTrainer/Config.swift
Normal 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 = ""
|
||||
}
|
||||
98
ios/OralTrainer/Models/AssessmentModels.swift
Normal 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) ?? [:]
|
||||
}
|
||||
}
|
||||
84
ios/OralTrainer/Models/PlaybackModels.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
151
ios/OralTrainer/Models/TrainingModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
12
ios/OralTrainer/OralTrainerApp.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OralTrainerApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
883
ios/OralTrainer/Player/PlayerController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
186
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 801 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/OralTrainer/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
52
ios/OralTrainer/Resources/Info.plist
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
CFBundleDisplayName = "八哥口语";
|
||||
159
ios/OralTrainer/Services/AudioRecorderSession.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum AudioRecorderSessionError: LocalizedError {
|
||||
case inputUnavailable
|
||||
case startFailed
|
||||
case conversionFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .inputUnavailable:
|
||||
return "未检测到麦克风输入"
|
||||
case .startFailed:
|
||||
return "无法开始录制:请确认麦克风未被其他应用占用后重试"
|
||||
case .conversionFailed:
|
||||
return "录音文件转码失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于 AVAudioEngine 的录音会话。
|
||||
///
|
||||
/// AVAudioRecorder 在 iOS 26 真机上存在 record() 恒返回 false 的已知问题
|
||||
/// (Apple 开发者论坛多个报告),因此这里改用 AVAudioEngine 采集 PCM,
|
||||
/// 停止录音时再转码为 16kHz 单声道 AAC(m4a),与服务端评测接口保持一致。
|
||||
final class AudioRecorderSession {
|
||||
private let engine = AVAudioEngine()
|
||||
private let outputURL: URL
|
||||
private let pcmURL: URL
|
||||
private var inputFile: AVAudioFile?
|
||||
private var tapInstalled = false
|
||||
|
||||
private(set) var isRunning = false
|
||||
private(set) var recordDuration: TimeInterval = 0
|
||||
|
||||
/// 本次录音的原始 PCM 文件(caf),停止录音后仍保留,
|
||||
/// 供配音合成直接合并,避免在 iOS 26 上读回 AAC(AVAudioFile 存在 'fmt?' 问题)。
|
||||
var rawRecordingURL: URL { pcmURL }
|
||||
|
||||
init(outputURL: URL) {
|
||||
self.outputURL = outputURL
|
||||
let stamp = Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.pcmURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("raw-\(stamp).caf")
|
||||
}
|
||||
|
||||
/// 激活音频会话并开始采集。失败时抛出错误,可在重新激活会话后重试。
|
||||
func start() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
guard session.isInputAvailable else {
|
||||
throw AudioRecorderSessionError.inputUnavailable
|
||||
}
|
||||
|
||||
let input = engine.inputNode
|
||||
if !tapInstalled {
|
||||
let inputFormat = input.outputFormat(forBus: 0)
|
||||
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
||||
throw AudioRecorderSessionError.inputUnavailable
|
||||
}
|
||||
let file = try AVAudioFile(forWriting: pcmURL, settings: inputFormat.settings)
|
||||
inputFile = file
|
||||
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
try self.inputFile?.write(from: buffer)
|
||||
} catch {
|
||||
// 单个缓冲写入失败时忽略,继续录音
|
||||
}
|
||||
}
|
||||
tapInstalled = true
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
throw AudioRecorderSessionError.startFailed
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
/// 停止录音,将 PCM 转码为 m4a 写入 outputURL。
|
||||
func stop() throws {
|
||||
guard isRunning else { return }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
tapInstalled = false
|
||||
inputFile = nil
|
||||
try finalizeToAAC()
|
||||
}
|
||||
|
||||
/// 放弃本次录音并清理临时文件。
|
||||
func cancel() {
|
||||
if isRunning {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
tapInstalled = false
|
||||
inputFile = nil
|
||||
}
|
||||
try? FileManager.default.removeItem(at: pcmURL)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
private func finalizeToAAC() throws {
|
||||
let inputFile = try AVAudioFile(forReading: pcmURL)
|
||||
recordDuration = Double(inputFile.length) / inputFile.processingFormat.sampleRate
|
||||
let outputSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
]
|
||||
let outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings)
|
||||
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFile.processingFormat) else {
|
||||
throw AudioRecorderSessionError.conversionFailed
|
||||
}
|
||||
let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
|
||||
if inputFile.framePosition >= inputFile.length {
|
||||
outStatus.pointee = .endOfStream
|
||||
return nil
|
||||
}
|
||||
let remaining = inputFile.length - inputFile.framePosition
|
||||
let frames = AVAudioFrameCount(min(inNumPackets, AVAudioFrameCount(remaining)))
|
||||
guard frames > 0,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFile.processingFormat, frameCapacity: frames) else {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
try inputFile.read(into: buffer)
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
} catch {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let capacity = AVAudioFrameCount(min(4096, max(1, Int(inputFile.length))))
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFile.processingFormat, frameCapacity: capacity) else {
|
||||
throw AudioRecorderSessionError.conversionFailed
|
||||
}
|
||||
while true {
|
||||
var conversionError: NSError?
|
||||
let status = converter.convert(to: outputBuffer, error: &conversionError, withInputFrom: inputBlock)
|
||||
switch status {
|
||||
case .haveData:
|
||||
try outputFile.write(from: outputBuffer)
|
||||
case .inputRanDry, .endOfStream:
|
||||
return
|
||||
case .error:
|
||||
throw conversionError ?? AudioRecorderSessionError.conversionFailed
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
154
ios/OralTrainer/Services/DubbingCompositor.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
ios/OralTrainer/Services/HTTPClient.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
229
ios/OralTrainer/Services/ImitationAssessor.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
222
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
67
ios/OralTrainer/Services/SentenceExplanationStore.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
final class SentenceExplanationStore {
|
||||
static let shared = SentenceExplanationStore()
|
||||
|
||||
private let root: URL
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
root = documents.appendingPathComponent("SentenceExplanations", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func load(mediaId: String, sentenceIndex: Int) -> String? {
|
||||
let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(mediaId: String, sentenceIndex: Int, explanation: String) -> Bool {
|
||||
let trimmed = explanation.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex)
|
||||
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
do {
|
||||
try trimmed.write(to: url, atomically: true, encoding: .utf8)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sync(mediaId: String, sentences: [SentenceBoundary]) {
|
||||
guard !sentences.isEmpty else { return }
|
||||
let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true)
|
||||
let revision = Self.revision(for: sentences)
|
||||
let marker = directory.appendingPathComponent("revision.txt")
|
||||
let previous = (try? String(contentsOf: marker, encoding: .utf8))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard previous != revision else { return }
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try? revision.write(to: marker, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func fileURL(mediaId: String, sentenceIndex: Int) -> URL {
|
||||
let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true)
|
||||
return directory.appendingPathComponent("\(sentenceIndex).txt")
|
||||
}
|
||||
|
||||
static func revision(for sentences: [SentenceBoundary]) -> String {
|
||||
sentences.map { sentence in
|
||||
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return "\(sentence.index)\t\(text)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func sanitize(_ mediaId: String) -> String {
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
||||
let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
let value = String(filtered).trimmingCharacters(in: CharacterSet(charactersIn: "_"))
|
||||
return value.isEmpty || value == "." || value == ".." ? "unknown" : value
|
||||
}
|
||||
}
|
||||
212
ios/OralTrainer/Services/StreamingVideoCache.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// iOS 端的可选整文件视频缓存。
|
||||
///
|
||||
/// 播放默认走远程 URL 按需加载(AVPlayer HTTP Range)。若本地已有完整缓存文件,
|
||||
/// 播放器会优先使用该文件,避免重复拉流。
|
||||
@MainActor
|
||||
final class StreamingVideoCache {
|
||||
static let shared = StreamingVideoCache()
|
||||
|
||||
static let maxCacheBytes: Int64 = 512 * 1024 * 1024
|
||||
|
||||
private static let supportedExtensions: Set<String> = ["mp4", "m4v", "mov"]
|
||||
private static let fileExtension = "mp4"
|
||||
|
||||
private let directory: URL
|
||||
private let fileManager = FileManager.default
|
||||
private var downloadsByURL: [URL: Task<URL, Error>] = [:]
|
||||
|
||||
private init() {
|
||||
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
directory = caches.appendingPathComponent("oral-trainer-streaming-videos", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func cachedFileIfAvailable(for remoteURL: URL) -> URL? {
|
||||
guard isCacheable(remoteURL), let file = destinationFile(for: remoteURL) else {
|
||||
return nil
|
||||
}
|
||||
guard fileManager.fileExists(atPath: file.path), isUsableMediaFile(file) else {
|
||||
fileManager.removeItemIfPossible(at: file)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AVPlayer 读取后刷新 LRU 时间,与 Android LeastRecentlyUsedCacheEvictor 的
|
||||
// 语义保持接近。
|
||||
try? fileManager.setAttributes(
|
||||
[.modificationDate: Date()],
|
||||
ofItemAtPath: file.path
|
||||
)
|
||||
return file
|
||||
}
|
||||
|
||||
/// 重复调用同一 URL 时复用同一个后台下载;只有下载完成后才返回本地文件。
|
||||
func prefetch(_ remoteURL: URL) async throws -> URL {
|
||||
if let cached = cachedFileIfAvailable(for: remoteURL) {
|
||||
return cached
|
||||
}
|
||||
guard let destination = destinationFile(for: remoteURL) else {
|
||||
throw StreamingVideoCacheError.unsupportedURL
|
||||
}
|
||||
guard isCacheable(remoteURL) else {
|
||||
throw StreamingVideoCacheError.unsupportedURL
|
||||
}
|
||||
|
||||
if let existing = downloadsByURL[remoteURL] {
|
||||
return try await existing.value
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
try await Self.download(from: remoteURL, to: destination)
|
||||
}
|
||||
downloadsByURL[remoteURL] = task
|
||||
|
||||
let result: URL
|
||||
do {
|
||||
result = try await task.value
|
||||
} catch {
|
||||
if !task.isCancelled {
|
||||
downloadsByURL[remoteURL] = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if downloadsByURL[remoteURL]?.isCancelled != true {
|
||||
downloadsByURL[remoteURL] = nil
|
||||
}
|
||||
evictIfNeeded(keeping: remoteURL)
|
||||
return result
|
||||
}
|
||||
|
||||
func clear() {
|
||||
for task in downloadsByURL.values {
|
||||
task.cancel()
|
||||
}
|
||||
downloadsByURL.removeAll()
|
||||
try? fileManager.removeItem(at: directory)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func isCacheable(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
let ext = url.pathExtension.lowercased()
|
||||
// HLS/DASH 播放列表不能通过下载单个清单完成离线缓存;当前云端课程均为 MP4。
|
||||
return Self.supportedExtensions.contains(ext)
|
||||
}
|
||||
|
||||
private func destinationFile(for remoteURL: URL) -> URL? {
|
||||
let rawData = Data(remoteURL.absoluteString.utf8)
|
||||
let digest = SHA256.hash(data: rawData)
|
||||
let key = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return directory.appendingPathComponent(key).appendingPathExtension(Self.fileExtension)
|
||||
}
|
||||
|
||||
private func isUsableMediaFile(_ url: URL) -> Bool {
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
|
||||
guard values?.isRegularFile == true, let fileSize = values?.fileSize else {
|
||||
return false
|
||||
}
|
||||
let size = Int64(fileSize)
|
||||
return size > 0
|
||||
}
|
||||
|
||||
private func evictIfNeeded(keeping remoteURL: URL) {
|
||||
guard let keeping = destinationFile(for: remoteURL) else {
|
||||
return
|
||||
}
|
||||
|
||||
let contents = (try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
|
||||
var entries: [(url: URL, size: Int64, date: Date)] = contents.compactMap { url in
|
||||
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||
guard let fileSize = values?.fileSize, fileSize > 0 else {
|
||||
fileManager.removeItemIfPossible(at: url)
|
||||
return nil
|
||||
}
|
||||
let size = Int64(fileSize)
|
||||
return (url, size, values?.contentModificationDate ?? .distantPast)
|
||||
}
|
||||
|
||||
var totalBytes = entries.reduce(0) { $0 + $1.size }
|
||||
entries.sort { $0.date < $1.date }
|
||||
for entry in entries where totalBytes > Self.maxCacheBytes && entry.url != keeping {
|
||||
guard fileManager.removeItemIfPossible(at: entry.url) else {
|
||||
continue
|
||||
}
|
||||
totalBytes -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
private static func download(from remoteURL: URL, to destination: URL) async throws -> URL {
|
||||
var request = URLRequest(url: remoteURL)
|
||||
request.timeoutInterval = AppConfig.connectTimeout
|
||||
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (tempFile, response) = try await URLSession.shared.download(for: request)
|
||||
do {
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw StreamingVideoCacheError.downloadFailed(message: "HTTP \(status)")
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: destination)
|
||||
try fileManager.createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try fileManager.moveItem(at: tempFile, to: destination)
|
||||
return destination
|
||||
} catch {
|
||||
FileManager.default.removeItemIfPossible(at: tempFile)
|
||||
if let cacheError = error as? StreamingVideoCacheError {
|
||||
logFailure(remoteURL, message: cacheError.localizedDescription)
|
||||
throw cacheError
|
||||
}
|
||||
logFailure(remoteURL, message: error.localizedDescription)
|
||||
throw StreamingVideoCacheError.downloadFailed(
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func logFailure(_ url: URL, message: String) {
|
||||
#if DEBUG
|
||||
print("[StreamingVideoCache] 预缓存失败: \(url), \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamingVideoCacheError: LocalizedError {
|
||||
case unsupportedURL
|
||||
case downloadFailed(message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedURL:
|
||||
return "仅支持 MP4/M4V/MOV 整文件缓存"
|
||||
case .downloadFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FileManager {
|
||||
@discardableResult
|
||||
func removeItemIfPossible(at url: URL) -> Bool {
|
||||
do {
|
||||
try removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
struct PersistedRecording {
|
||||
let audioURL: URL
|
||||
let rawURL: URL
|
||||
}
|
||||
|
||||
struct TrainingHistorySnapshot {
|
||||
var audioSegments: [Int: URL] = [:]
|
||||
var rawSegments: [Int: URL] = [:]
|
||||
var scores: [Int: ImitationAssessmentResult] = [:]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TrainingHistoryManager: ObservableObject {
|
||||
@Published private(set) var historyVersion = 0
|
||||
let store = TrainingHistoryStore()
|
||||
|
||||
func notifyHistoryChanged() {
|
||||
historyVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
final class TrainingHistoryStore {
|
||||
private let root: URL
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
root = documents.appendingPathComponent("TrainingHistory", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func normalizedVideoHash(_ videoHash: String) -> String {
|
||||
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) }
|
||||
return String(String.UnicodeScalarView(filtered))
|
||||
}
|
||||
|
||||
private var userDirectory: URL {
|
||||
let identifier = normalizedVideoHash(AppConfig.currentUserId)
|
||||
return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true)
|
||||
}
|
||||
|
||||
func load(videoHash: String) -> TrainingHistorySnapshot {
|
||||
let directory = courseDirectory(videoHash)
|
||||
var snapshot = TrainingHistorySnapshot()
|
||||
for index in 0..<500 {
|
||||
let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index))
|
||||
let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index))
|
||||
if FileManager.default.fileExists(atPath: audio.path) {
|
||||
snapshot.audioSegments[index] = audio
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: raw.path) {
|
||||
snapshot.rawSegments[index] = raw
|
||||
}
|
||||
}
|
||||
|
||||
let scoreFile = directory.appendingPathComponent("scores.json")
|
||||
if let data = try? Data(contentsOf: scoreFile),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
for (key, value) in object {
|
||||
guard let index = Int(key), index >= 0 else { continue }
|
||||
if let payload = value as? [String: Any],
|
||||
let payloadData = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) {
|
||||
snapshot.scores[index] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func saveSegment(
|
||||
videoHash: String,
|
||||
sentenceIndex: Int,
|
||||
audioURL: URL,
|
||||
rawURL: URL
|
||||
) throws -> PersistedRecording {
|
||||
let directory = courseDirectory(videoHash)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let savedAudio = directory.appendingPathComponent(
|
||||
String(format: "segment-%04d.m4a", sentenceIndex)
|
||||
)
|
||||
let savedRaw = directory.appendingPathComponent(
|
||||
String(format: "raw-%04d.caf", sentenceIndex)
|
||||
)
|
||||
try? FileManager.default.removeItem(at: savedAudio)
|
||||
try? FileManager.default.removeItem(at: savedRaw)
|
||||
try FileManager.default.moveItem(at: audioURL, to: savedAudio)
|
||||
try FileManager.default.moveItem(at: rawURL, to: savedRaw)
|
||||
return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw)
|
||||
}
|
||||
|
||||
func audioDestination(videoHash: String, sentenceIndex: Int) -> URL {
|
||||
courseDirectory(videoHash).appendingPathComponent(
|
||||
String(format: "segment-%04d.m4a", sentenceIndex)
|
||||
)
|
||||
}
|
||||
|
||||
func saveScores(
|
||||
videoHash: String,
|
||||
scores: [Int: ImitationAssessmentResult]
|
||||
) {
|
||||
let directory = courseDirectory(videoHash)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
var object: [String: Any] = [:]
|
||||
for (index, score) in scores {
|
||||
object[String(index)] = Self.payload(score)
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
||||
try data.write(
|
||||
to: directory.appendingPathComponent("scores.json"),
|
||||
options: [.atomic]
|
||||
)
|
||||
} catch {
|
||||
// History persistence is best-effort; the server remains the source of record.
|
||||
}
|
||||
}
|
||||
|
||||
func removeScore(videoHash: String, sentenceIndex: Int) {
|
||||
var snapshot = load(videoHash: videoHash)
|
||||
snapshot.scores[sentenceIndex] = nil
|
||||
saveScores(videoHash: videoHash, scores: snapshot.scores)
|
||||
}
|
||||
|
||||
func removeAllAudio(videoHash: String) {
|
||||
let snapshot = load(videoHash: videoHash)
|
||||
for url in snapshot.audioSegments.values {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
for url in snapshot.rawSegments.values {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func courseDirectory(_ videoHash: String) -> URL {
|
||||
userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true)
|
||||
}
|
||||
|
||||
private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] {
|
||||
var value: [String: Any] = ["overall_score": score.overallScore]
|
||||
if let item = score.passed { value["passed"] = item }
|
||||
if let item = score.passScore { value["pass_score"] = item }
|
||||
if let item = score.contentScore { value["content_score"] = item }
|
||||
if let item = score.completenessScore { value["completeness_score"] = item }
|
||||
if let item = score.fluencyScore { value["fluency_score"] = item }
|
||||
if let item = score.pronunciationScore { value["pronunciation_score"] = item }
|
||||
if let item = score.prosodyScore { value["prosody_score"] = item }
|
||||
if let item = score.durationScore { value["duration_score"] = item }
|
||||
if let item = score.pauseScore { value["pause_score"] = item }
|
||||
if let item = score.speechRateScore { value["speech_rate_score"] = item }
|
||||
if let item = score.referenceText { value["reference_text"] = item }
|
||||
if let item = score.recognizedText { value["recognized_text"] = item }
|
||||
if let item = score.durationRatio { value["duration_ratio"] = item }
|
||||
if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens }
|
||||
if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens }
|
||||
if !score.substitutions.isEmpty {
|
||||
value["substitutions"] = score.substitutions.map {
|
||||
["expected": $0.expected, "actual": $0.actual]
|
||||
}
|
||||
}
|
||||
if let item = score.feedback { value["feedback"] = item }
|
||||
if !score.details.isEmpty { value["details"] = score.details }
|
||||
return value
|
||||
}
|
||||
}
|
||||
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
@@ -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)"]
|
||||
}
|
||||
}
|
||||
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal 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)
|
||||
}
|
||||
}
|
||||