beautified some ui
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ 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
|
||||
|
||||
var config: PlayerConfig = initialConfig
|
||||
private set
|
||||
@@ -42,6 +44,7 @@ class OralTrainerController internal constructor(
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
enforceSentenceBoundary()
|
||||
notifySentenceIfChanged()
|
||||
notifySnapshot()
|
||||
mainHandler.postDelayed(this, 250L)
|
||||
@@ -124,6 +127,8 @@ class OralTrainerController internal constructor(
|
||||
mediaItems.clear()
|
||||
mediaItems.addAll(items)
|
||||
lastSentenceIndex = null
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs))
|
||||
player.prepare()
|
||||
player.playWhenReady = config.autoPlay
|
||||
@@ -141,6 +146,8 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.pause()
|
||||
}
|
||||
|
||||
@@ -153,10 +160,14 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.stop()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.seekTo(max(0L, positionMs))
|
||||
}
|
||||
|
||||
@@ -202,6 +213,18 @@ class OralTrainerController internal constructor(
|
||||
config = config.copy(sentenceMode = enabled)
|
||||
}
|
||||
|
||||
fun setContinuousPlayback(enabled: Boolean) {
|
||||
config = config.copy(continuousPlayback = enabled)
|
||||
}
|
||||
|
||||
fun playCurrentSentenceAndStop() {
|
||||
val sentence = currentSentence() ?: return
|
||||
player.seekTo(sentence.startMs)
|
||||
stopAtSentenceEnd = true
|
||||
stopAtSentenceEndIndex = sentence.index
|
||||
player.play()
|
||||
}
|
||||
|
||||
fun currentTrainingItem(): TrainingMediaItem? {
|
||||
val index = player.currentMediaItemIndex
|
||||
return mediaItems.getOrNull(index)
|
||||
@@ -335,6 +358,36 @@ class OralTrainerController internal constructor(
|
||||
listeners.forEach { it.onSentenceChanged(sentence) }
|
||||
}
|
||||
|
||||
private fun enforceSentenceBoundary() {
|
||||
if (!config.sentenceMode || !player.isPlaying) {
|
||||
return
|
||||
}
|
||||
val item = currentTrainingItem() ?: return
|
||||
if (item.sentences.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val position = player.currentPosition
|
||||
if (stopAtSentenceEnd) {
|
||||
val index = stopAtSentenceEndIndex ?: return
|
||||
val sentence = item.sentences.getOrNull(index) ?: return
|
||||
if (position >= sentence.endMs) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.pause()
|
||||
player.seekTo(sentence.startMs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (config.continuousPlayback) {
|
||||
return
|
||||
}
|
||||
val anchorIndex = lastSentenceIndex ?: return
|
||||
val anchor = item.sentences.getOrNull(anchorIndex) ?: return
|
||||
if (position >= anchor.endMs) {
|
||||
player.seekTo(anchor.startMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TrainingMediaItem.toMedia3Item(): MediaItem {
|
||||
val builder = MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
|
||||
@@ -10,4 +10,5 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
|
||||
val readTimeoutMs: Int = 30_000,
|
||||
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
||||
val assessmentApiKey: 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,43 +65,45 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
require(SHA256_PATTERN.matches(videoHash)) {
|
||||
"ImitationAssessmentRequest.mediaId or metadata[video_hash] must be a SHA-256 hash."
|
||||
}
|
||||
val endpoint = baseUri.buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash.lowercase())
|
||||
.appendPath("sentences")
|
||||
.appendPath(request.sentence.index.toString())
|
||||
.appendPath("assessments")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
val boundary = "----OralTrainer-${UUID.randomUUID()}"
|
||||
return try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = maxOf(config.readTimeoutMs, 180_000)
|
||||
connection.setChunkedStreamingMode(64 * 1024)
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash.lowercase())
|
||||
.appendPath("sentences")
|
||||
.appendPath(request.sentence.index.toString())
|
||||
.appendPath("assessments")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
val boundary = "----OralTrainer-${UUID.randomUUID()}"
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = maxOf(config.readTimeoutMs, 180_000)
|
||||
connection.setChunkedStreamingMode(64 * 1024)
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
output.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw RemoteAssessmentException(statusCode, body)
|
||||
}
|
||||
parseResponse(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
output.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw RemoteAssessmentException(statusCode, body)
|
||||
}
|
||||
parseResponse(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,33 +98,35 @@ class SentenceBoundaryApi internal constructor(
|
||||
}
|
||||
|
||||
private fun fetchBlocking(videoHash: String): SentenceBoundaryApiResult {
|
||||
val endpoint = baseUri.buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash)
|
||||
.appendPath("sentence-boundaries")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash)
|
||||
.appendPath("sentence-boundaries")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream
|
||||
}
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw SentenceBoundaryApiException(statusCode, body)
|
||||
}
|
||||
parseResponse(body, videoHash)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw SentenceBoundaryApiException(statusCode, body)
|
||||
}
|
||||
parseResponse(body, videoHash)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ class VideoCatalogApi internal constructor(
|
||||
) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val baseUrl = config.sentenceBoundaryApiBaseUrl.trimEnd('/') + "/"
|
||||
|
||||
fun fetch(callback: VideoCatalogCallback): CancellableRequest {
|
||||
val future = executor.submit {
|
||||
@@ -67,37 +66,45 @@ class VideoCatalogApi internal constructor(
|
||||
}
|
||||
|
||||
private fun fetchBlocking(): List<TrainingVideoSummary> {
|
||||
val endpoint = URL(URL(baseUrl), "api/v1/videos")
|
||||
val connection = endpoint.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw VideoCatalogApiException(statusCode, body)
|
||||
parseResponse(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = URL(URL(effectiveBaseUrl), "api/v1/videos")
|
||||
val connection = endpoint.openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw VideoCatalogApiException(statusCode, body)
|
||||
parseResponse(body, effectiveBaseUrl)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResponse(body: String): List<TrainingVideoSummary> {
|
||||
private fun parseResponse(body: String, effectiveBaseUrl: String): List<TrainingVideoSummary> {
|
||||
val videos = JSONObject(body).getJSONArray("videos")
|
||||
val effectiveScheme = Uri.parse(effectiveBaseUrl).scheme
|
||||
return buildList(videos.length()) {
|
||||
for (index in 0 until videos.length()) {
|
||||
val item = videos.getJSONObject(index)
|
||||
val status = item.getString("status")
|
||||
if (status != "ready") continue
|
||||
val streamUrl = URL(URL(baseUrl), item.getString("stream_url"))
|
||||
val resolvedStreamUrl = URL(URL(effectiveBaseUrl), item.getString("stream_url")).toString()
|
||||
val streamUri = if (effectiveScheme == "http") {
|
||||
Uri.parse(resolvedStreamUrl).buildUpon().scheme("http").build().toString()
|
||||
} else {
|
||||
resolvedStreamUrl
|
||||
}
|
||||
add(
|
||||
TrainingVideoSummary(
|
||||
videoHash = item.getString("video_hash"),
|
||||
title = item.getString("title"),
|
||||
streamUri = Uri.parse(streamUrl.toString()),
|
||||
streamUri = Uri.parse(streamUri),
|
||||
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
|
||||
sizeBytes = item.getLong("size_bytes"),
|
||||
language = if (item.isNull("language")) null else item.getString("language"),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<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" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.media.MediaRecorder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
@@ -14,17 +20,22 @@ import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import androidx.media3.common.MimeTypes
|
||||
import cn.learningpad.oraltrainer.sdk.GestureEvent
|
||||
import cn.learningpad.oraltrainer.sdk.GestureKind
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import cn.learningpad.oraltrainer.sdk.LoopMode
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerListener
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
|
||||
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
|
||||
import cn.learningpad.oraltrainer.sdk.PlayerConfig
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
|
||||
@@ -33,11 +44,13 @@ import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity : Activity() {
|
||||
private lateinit var sdk: OralTrainerSdk
|
||||
private lateinit var controller: OralTrainerController
|
||||
private lateinit var controllerListener: OralTrainerListener
|
||||
private lateinit var playerView: OralTrainerPlayerView
|
||||
private lateinit var lessonTitleText: TextView
|
||||
private lateinit var statusText: TextView
|
||||
@@ -45,32 +58,78 @@ class MainActivity : Activity() {
|
||||
private lateinit var sentenceMetaText: TextView
|
||||
private lateinit var timeText: TextView
|
||||
private lateinit var speedText: TextView
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var progressBar: SeekBar
|
||||
private var progressBarDragging = false
|
||||
private lateinit var catalogList: LinearLayout
|
||||
private lateinit var catalogStatusText: TextView
|
||||
private lateinit var testStatusText: TextView
|
||||
private lateinit var scoreSummaryText: TextView
|
||||
private lateinit var scoreDetailText: TextView
|
||||
private lateinit var recordButton: Button
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var recordingFile: File? = null
|
||||
|
||||
private var activeItemId: String = SAMPLE_ID
|
||||
private var activeItemId: String = ""
|
||||
private var currentSentenceCount = 0
|
||||
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||
private var catalogStatusTextValue = "正在同步"
|
||||
private var activeModule = Module.TRAIN
|
||||
private var continuousPlaybackEnabled = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
configureWindow()
|
||||
|
||||
sdk = OralTrainerSdk.init(this)
|
||||
sdk = OralTrainerSdk.init(
|
||||
this,
|
||||
OralTrainerSdkConfig(
|
||||
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
|
||||
assessmentApiKey = ASSESSMENT_API_KEY,
|
||||
allowHttpFallback = true,
|
||||
)
|
||||
)
|
||||
controller = sdk.createController(
|
||||
playerConfig = PlayerConfig(
|
||||
sentenceMode = true,
|
||||
defaultSeekStepMs = 10_000L,
|
||||
autoPlay = false,
|
||||
)
|
||||
continuousPlayback = false,
|
||||
),
|
||||
imitationAssessor = sdk.createRemoteImitationQualityAssessor(),
|
||||
)
|
||||
controller.setLoopMode(LoopMode.ALL)
|
||||
controllerListener = createControllerListener()
|
||||
controller.addListener(controllerListener)
|
||||
|
||||
setContentView(createContentView())
|
||||
bindPlayerEvents()
|
||||
loadSampleLesson()
|
||||
loadCatalog()
|
||||
refreshCurrentUi()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
setContentView(createContentView())
|
||||
renderCatalog()
|
||||
refreshCurrentUi()
|
||||
if (activeModule == Module.TEST) {
|
||||
refreshTestUi()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != RECORD_AUDIO_REQUEST) {
|
||||
return
|
||||
}
|
||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
startRecording()
|
||||
} else if (::testStatusText.isInitialized) {
|
||||
testStatusText.text = "需要麦克风权限才能进行录音评测"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
@@ -122,6 +181,7 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
discardRecording()
|
||||
controller.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -133,13 +193,117 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun createContentView(): View {
|
||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
return if (isLandscape) {
|
||||
createLandscapeContentView()
|
||||
} else {
|
||||
createPortraitContentView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPortraitContentView(): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(COLOR_BACKGROUND)
|
||||
addView(createModuleTabBar())
|
||||
if (activeModule == Module.TEST) {
|
||||
addView(createTestContentView())
|
||||
} else {
|
||||
addView(createHeader())
|
||||
addView(createPlayerSection())
|
||||
addView(createSentenceSection())
|
||||
addView(createCatalogSection())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTestContentView(): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(COLOR_BACKGROUND)
|
||||
addView(createHeader())
|
||||
addView(createPlayerSection())
|
||||
addView(createSentenceSection())
|
||||
addView(createCatalogSection())
|
||||
addView(createTestSection())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createModuleTabBar(): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(16.dp, 40.dp, 16.dp, 0)
|
||||
addView(moduleTab("训练", Module.TRAIN))
|
||||
addView(moduleTab("测试", Module.TEST))
|
||||
}
|
||||
}
|
||||
|
||||
private fun moduleTab(label: String, module: Module): TextView {
|
||||
val selected = activeModule == module
|
||||
return TextView(this).apply {
|
||||
text = label
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
isClickable = true
|
||||
setTextColor(if (selected) Color.WHITE else COLOR_TEXT_MUTED)
|
||||
background = if (selected) {
|
||||
rounded(COLOR_BUTTON, 8f)
|
||||
} else {
|
||||
rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener {
|
||||
if (activeModule == module) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (activeModule == Module.TEST) {
|
||||
discardRecording()
|
||||
}
|
||||
activeModule = module
|
||||
controller.setContinuousPlayback(
|
||||
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else true
|
||||
)
|
||||
setContentView(createContentView())
|
||||
renderCatalog()
|
||||
refreshCurrentUi()
|
||||
if (activeModule == Module.TEST) {
|
||||
refreshTestUi()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLandscapeContentView(): View {
|
||||
return FrameLayout(this).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(createPlayerView())
|
||||
addView(createPlayerOverlay())
|
||||
addView(
|
||||
LinearLayout(this@MainActivity).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(createRotationButtonRow())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLandscapeSentencePanel(): View {
|
||||
return createSentenceContent(translucent = true).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,16 +313,16 @@ class MainActivity : Activity() {
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "口语宝"
|
||||
text = "跟读虫"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 26f
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
includeFontPadding = false
|
||||
})
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "中英跟读训练"
|
||||
text = "外语跟读训练神器"
|
||||
setTextColor(COLOR_TEXT_MUTED)
|
||||
textSize = 13f
|
||||
textSize = 20f
|
||||
setPadding(1.dp, 4.dp, 0, 0)
|
||||
})
|
||||
}
|
||||
@@ -174,14 +338,40 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun createPlayerSection(): View {
|
||||
playerView = OralTrainerPlayerView(this).apply {
|
||||
return FrameLayout(this).apply {
|
||||
background = rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
|
||||
clipToOutline = true
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
).withMargins(16.dp, 0, 16.dp, 10.dp)
|
||||
addView(createPlayerView())
|
||||
addView(createPlayerOverlay())
|
||||
addView(createRotationButton().apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
44.dp,
|
||||
44.dp,
|
||||
Gravity.BOTTOM or Gravity.END,
|
||||
).apply {
|
||||
marginEnd = 12.dp
|
||||
bottomMargin = 12.dp
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlayerView(): OralTrainerPlayerView {
|
||||
return OralTrainerPlayerView(this).apply {
|
||||
bind(controller)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
}.also { playerView = it }
|
||||
}
|
||||
|
||||
private fun createPlayerOverlay(): View {
|
||||
val overlay = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
@@ -192,71 +382,123 @@ class MainActivity : Activity() {
|
||||
Gravity.TOP,
|
||||
)
|
||||
}
|
||||
|
||||
timeText = overlayPill("--:-- / --:--").apply {
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
speedText = overlayPill("1x")
|
||||
overlay.addView(timeText)
|
||||
overlay.addView(speedText)
|
||||
return overlay
|
||||
}
|
||||
|
||||
return FrameLayout(this).apply {
|
||||
background = rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
|
||||
clipToOutline = true
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
).withMargins(16.dp, 0, 16.dp, 10.dp)
|
||||
addView(playerView)
|
||||
addView(overlay)
|
||||
private fun createRotationButtonRow(): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.END
|
||||
setPadding(0, 0, 12.dp, 12.dp)
|
||||
addView(createRotationButton())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRotationButton(): ImageButton {
|
||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
return ImageButton(this).apply {
|
||||
setImageResource(R.drawable.ic_screen_rotation)
|
||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
background = rounded(Color.argb(178, 9, 12, 16), 22.dp.toFloat())
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
setPadding(10.dp, 10.dp, 10.dp, 10.dp)
|
||||
contentDescription = if (isLandscape) "竖屏" else "横屏"
|
||||
layoutParams = LinearLayout.LayoutParams(44.dp, 44.dp)
|
||||
setOnClickListener { toggleOrientation() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleOrientation() {
|
||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
requestedOrientation = if (isLandscape) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSentenceSection(): View {
|
||||
lessonTitleText = TextView(this).apply {
|
||||
setTextColor(COLOR_TEXT_DARK)
|
||||
textSize = 17f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 1
|
||||
}
|
||||
sentenceMetaText = TextView(this).apply {
|
||||
setTextColor(COLOR_ACCENT_DEEP)
|
||||
textSize = 13f
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
sentenceText = TextView(this).apply {
|
||||
setTextColor(COLOR_TEXT_DARK)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 10.dp, 0, 10.dp)
|
||||
}
|
||||
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
max = PROGRESS_MAX
|
||||
progress = 0
|
||||
progressTintList = ColorStateList.valueOf(COLOR_ACCENT)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(COLOR_PROGRESS_TRACK)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
6.dp,
|
||||
)
|
||||
}
|
||||
statusText = TextView(this).apply {
|
||||
setTextColor(COLOR_TEXT_SUBTLE)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
}
|
||||
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
return createSentenceContent(translucent = false).apply {
|
||||
background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER)
|
||||
setPadding(18.dp, 16.dp, 18.dp, 16.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(16.dp, 0, 16.dp, 12.dp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSentenceContent(translucent: Boolean): LinearLayout {
|
||||
lessonTitleText = TextView(this).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK)
|
||||
textSize = 17f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 1
|
||||
}
|
||||
sentenceMetaText = TextView(this).apply {
|
||||
setTextColor(if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT_DEEP)
|
||||
textSize = 13f
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
sentenceText = TextView(this).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 10.dp, 0, 10.dp)
|
||||
}
|
||||
progressBar = SeekBar(this).apply {
|
||||
max = PROGRESS_MAX
|
||||
progress = 0
|
||||
progressTintList = ColorStateList.valueOf(COLOR_ACCENT)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(
|
||||
if (translucent) COLOR_PROGRESS_TRACK_DARK else COLOR_PROGRESS_TRACK
|
||||
)
|
||||
thumbTintList = ColorStateList.valueOf(COLOR_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) {
|
||||
val duration = controller.snapshot().durationMs
|
||||
timeText.text = "${formatTime(seekPositionForProgress(progress))} / ${formatTime(duration)}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
progressBarDragging = true
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
progressBarDragging = false
|
||||
controller.seekTo(seekPositionForProgress(seekBar.progress))
|
||||
}
|
||||
})
|
||||
}
|
||||
statusText = TextView(this).apply {
|
||||
setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
}
|
||||
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
if (translucent) {
|
||||
background = rounded(COLOR_OVERLAY, 0f, null)
|
||||
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
|
||||
}
|
||||
addView(lessonTitleText)
|
||||
if (activeModule == Module.TRAIN) {
|
||||
addView(createContinuousPlaybackRow(translucent))
|
||||
}
|
||||
addView(sentenceMetaText)
|
||||
addView(sentenceText)
|
||||
addView(progressBar)
|
||||
@@ -264,6 +506,36 @@ class MainActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createContinuousPlaybackRow(translucent: Boolean): View {
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "连续播放"
|
||||
setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(this@MainActivity).apply {
|
||||
isChecked = continuousPlaybackEnabled
|
||||
buttonTintList = ColorStateList.valueOf(
|
||||
if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT
|
||||
)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
continuousPlaybackEnabled = checked
|
||||
controller.setContinuousPlayback(checked)
|
||||
statusText.text = if (checked) {
|
||||
"已开启连续播放,将按句子顺序连续播放"
|
||||
} else {
|
||||
"已关闭连续播放,将循环播放当前句子"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCatalogSection(): View {
|
||||
catalogStatusText = TextView(this).apply {
|
||||
setTextColor(COLOR_TEXT_MUTED)
|
||||
@@ -302,13 +574,243 @@ class MainActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPlayerEvents() {
|
||||
controller.addListener(object : OralTrainerListener {
|
||||
private fun createTestSection(): View {
|
||||
testStatusText = TextView(this).apply {
|
||||
setTextColor(COLOR_ACCENT_DEEP)
|
||||
textSize = 13f
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
scoreSummaryText = TextView(this).apply {
|
||||
setTextColor(COLOR_TEXT_DARK)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
text = "尚未评测"
|
||||
}
|
||||
scoreDetailText = TextView(this).apply {
|
||||
setTextColor(COLOR_TEXT_SUBTLE)
|
||||
textSize = 13f
|
||||
setLineSpacing(4.dp.toFloat(), 1f)
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
|
||||
val sentenceButton = { label: String, action: () -> Unit ->
|
||||
Button(this).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(COLOR_BUTTON, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
}
|
||||
|
||||
recordButton = Button(this).apply {
|
||||
text = "开始录音"
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(COLOR_ACCENT, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
setOnClickListener {
|
||||
if (mediaRecorder == null) {
|
||||
startRecording()
|
||||
} else {
|
||||
stopRecordingAndAssess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val controlRow = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(sentenceButton("上一句") { controller.seekToPreviousSentence() })
|
||||
addView(sentenceButton("播放原句") {
|
||||
controller.playCurrentSentenceAndStop()
|
||||
statusText.text = "正在播放当前句子,播放完自动停止"
|
||||
})
|
||||
addView(sentenceButton("下一句") { controller.seekToNextSentence() })
|
||||
}
|
||||
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER)
|
||||
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(this@MainActivity).apply {
|
||||
text = "朗读评测(测试)"
|
||||
setTextColor(COLOR_TEXT_DARK)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
})
|
||||
addView(testStatusText)
|
||||
addView(controlRow)
|
||||
addView(recordButton)
|
||||
addView(scoreSummaryText)
|
||||
addView(scoreDetailText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshTestUi() {
|
||||
if (!::testStatusText.isInitialized) {
|
||||
return
|
||||
}
|
||||
recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测"
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
testStatusText.text = if (item == null || sentence == null) {
|
||||
"请先在训练模块选择一个云端课程"
|
||||
} else {
|
||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
controller.pause()
|
||||
if (!::testStatusText.isInitialized) {
|
||||
return
|
||||
}
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RECORD_AUDIO_REQUEST)
|
||||
return
|
||||
}
|
||||
val file = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a")
|
||||
val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
try {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioSamplingRate(16_000)
|
||||
recorder.setAudioEncodingBitRate(96_000)
|
||||
recorder.setOutputFile(file.absolutePath)
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
mediaRecorder = recorder
|
||||
recordingFile = file
|
||||
recordButton.text = "停止并评测"
|
||||
testStatusText.text = "正在录音…读完当前句子后点击“停止并评测”"
|
||||
} catch (error: Throwable) {
|
||||
testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingAndAssess() {
|
||||
val recorder = mediaRecorder ?: return
|
||||
val file = recordingFile ?: return
|
||||
mediaRecorder = null
|
||||
recordButton.text = "开始录音"
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (error: Throwable) {
|
||||
testStatusText.text = "录音太短或无法保存:${error.message.orEmpty()}"
|
||||
file.delete()
|
||||
recordingFile = null
|
||||
return
|
||||
} finally {
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
recordingFile = null
|
||||
submitAssessment(Uri.fromFile(file))
|
||||
}
|
||||
|
||||
private fun discardRecording() {
|
||||
val recorder = mediaRecorder ?: return
|
||||
mediaRecorder = null
|
||||
runCatching { recorder.stop() }
|
||||
runCatching { recorder.release() }
|
||||
recordingFile?.delete()
|
||||
recordingFile = null
|
||||
}
|
||||
|
||||
private fun submitAssessment(recordingUri: Uri) {
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
if (item == null || sentence == null) {
|
||||
testStatusText.text = "没有正在学习的句子,无法评测"
|
||||
return
|
||||
}
|
||||
if (!SHA256_PATTERN.matches(item.id)) {
|
||||
testStatusText.text = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
|
||||
return
|
||||
}
|
||||
if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) {
|
||||
testStatusText.text = "未配置评分密钥:请在 MainActivity 填入服务器 .env 的 CLIENT_API_KEY"
|
||||
return
|
||||
}
|
||||
testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
|
||||
scoreSummaryText.text = "评测中…"
|
||||
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) {
|
||||
if (::testStatusText.isInitialized) {
|
||||
applyAssessmentResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (::testStatusText.isInitialized) {
|
||||
testStatusText.text = "评测失败:${error.message.orEmpty()}"
|
||||
scoreSummaryText.text = "评测失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyAssessmentResult(result: ImitationAssessmentResult) {
|
||||
testStatusText.text = if (result.passed == true) "已通过" else "未通过"
|
||||
scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
|
||||
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 createControllerListener(): OralTrainerListener {
|
||||
return object : OralTrainerListener {
|
||||
override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
|
||||
timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
|
||||
speedText.text = formatSpeed(snapshot.playbackSpeed)
|
||||
statusText.text = playbackStatus(snapshot)
|
||||
progressBar.progress = playbackProgress(snapshot)
|
||||
applyPlaybackSnapshot(snapshot)
|
||||
}
|
||||
|
||||
override fun onMediaChanged(item: TrainingMediaItem?) {
|
||||
@@ -317,18 +819,7 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
override fun onSentenceChanged(sentence: SentenceBoundary?) {
|
||||
if (sentence == null) {
|
||||
sentenceMetaText.text = "暂无句子边界"
|
||||
sentenceText.text = lessonTitleText.text
|
||||
return
|
||||
}
|
||||
val countText = if (currentSentenceCount > 0) {
|
||||
" / $currentSentenceCount"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
sentenceMetaText.text = "第 ${sentence.index + 1}$countText 句 ${formatTime(sentence.startMs)}-${formatTime(sentence.endMs)}"
|
||||
sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
|
||||
applySentence(sentence)
|
||||
}
|
||||
|
||||
override fun onGesture(event: GestureEvent) {
|
||||
@@ -343,33 +834,76 @@ class MainActivity : Activity() {
|
||||
override fun onPlayerError(error: Throwable) {
|
||||
statusText.text = "播放失败:${error.message.orEmpty()}"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSampleLesson() {
|
||||
val item = sampleOnlineLesson()
|
||||
activeItemId = item.id
|
||||
currentSentenceCount = item.sentences.size
|
||||
controller.loadItem(item)
|
||||
lessonTitleText.text = item.title
|
||||
renderCatalog()
|
||||
private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) {
|
||||
if (!progressBarDragging) {
|
||||
timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
|
||||
progressBar.progress = playbackProgress(snapshot)
|
||||
}
|
||||
speedText.text = formatSpeed(snapshot.playbackSpeed)
|
||||
statusText.text = playbackStatus(snapshot)
|
||||
}
|
||||
|
||||
private fun seekPositionForProgress(progress: Int): Long {
|
||||
val duration = controller.snapshot().durationMs
|
||||
if (duration <= 0L) {
|
||||
return 0L
|
||||
}
|
||||
return progress.toLong() * duration / PROGRESS_MAX
|
||||
}
|
||||
|
||||
private fun applySentence(sentence: SentenceBoundary?) {
|
||||
if (sentence == null) {
|
||||
sentenceMetaText.text = "暂无句子边界"
|
||||
sentenceText.text = lessonTitleText.text
|
||||
return
|
||||
}
|
||||
val countText = if (currentSentenceCount > 0) {
|
||||
" / $currentSentenceCount"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
sentenceMetaText.text = "第 ${sentence.index + 1}$countText 句 ${formatTime(sentence.startMs)}-${formatTime(sentence.endMs)}"
|
||||
sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
|
||||
}
|
||||
|
||||
private fun refreshCurrentUi() {
|
||||
val item = controller.currentTrainingItem()
|
||||
lessonTitleText.text = item?.title ?: "未选择课程"
|
||||
currentSentenceCount = item?.sentences?.size ?: 0
|
||||
applyPlaybackSnapshot(controller.snapshot())
|
||||
applySentence(controller.currentSentence())
|
||||
if (::catalogStatusText.isInitialized) {
|
||||
catalogStatusText.text = catalogStatusTextValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCatalog() {
|
||||
catalogStatusText.text = "正在同步"
|
||||
catalogStatusTextValue = "正在同步"
|
||||
if (::catalogStatusText.isInitialized) {
|
||||
catalogStatusText.text = catalogStatusTextValue
|
||||
}
|
||||
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
|
||||
override fun onSuccess(videos: List<TrainingVideoSummary>) {
|
||||
catalogVideos = videos
|
||||
catalogStatusText.text = if (videos.isEmpty()) {
|
||||
catalogStatusTextValue = if (videos.isEmpty()) {
|
||||
"暂无云端课程"
|
||||
} else {
|
||||
"${videos.size} 个云端课程"
|
||||
}
|
||||
if (::catalogStatusText.isInitialized) {
|
||||
catalogStatusText.text = catalogStatusTextValue
|
||||
}
|
||||
renderCatalog()
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
catalogStatusText.text = "云端暂不可用"
|
||||
catalogStatusTextValue = "云端暂不可用:${error.message.orEmpty()}"
|
||||
if (::catalogStatusText.isInitialized) {
|
||||
catalogStatusText.text = catalogStatusTextValue
|
||||
}
|
||||
renderCatalog()
|
||||
}
|
||||
})
|
||||
@@ -380,15 +914,6 @@ class MainActivity : Activity() {
|
||||
return
|
||||
}
|
||||
catalogList.removeAllViews()
|
||||
catalogList.addView(
|
||||
videoCard(
|
||||
id = SAMPLE_ID,
|
||||
title = "示例课程",
|
||||
meta = "4 句 · ${formatTime(16_000L)}",
|
||||
) {
|
||||
loadSampleLesson()
|
||||
}
|
||||
)
|
||||
catalogVideos.forEach { video ->
|
||||
catalogList.addView(
|
||||
videoCard(
|
||||
@@ -516,22 +1041,6 @@ class MainActivity : Activity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleOnlineLesson(): TrainingMediaItem {
|
||||
return TrainingMediaItem(
|
||||
id = SAMPLE_ID,
|
||||
title = "口语宝示例课",
|
||||
uri = Uri.parse("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
|
||||
mimeType = MimeTypes.VIDEO_MP4,
|
||||
customCacheKey = SAMPLE_ID,
|
||||
sentences = listOf(
|
||||
SentenceBoundary(0, 0L, 3_000L, "Good morning, everyone."),
|
||||
SentenceBoundary(1, 3_000L, 7_000L, "Today we will practice listening carefully."),
|
||||
SentenceBoundary(2, 7_000L, 11_000L, "Please repeat each sentence clearly."),
|
||||
SentenceBoundary(3, 11_000L, 16_000L, "Small daily practice makes progress visible."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun playbackStatus(snapshot: PlaybackSnapshot): String {
|
||||
val state = when {
|
||||
snapshot.isPlaying -> "播放中"
|
||||
@@ -599,22 +1108,38 @@ class MainActivity : Activity() {
|
||||
get() = (this * resources.displayMetrics.density).toInt()
|
||||
|
||||
private companion object {
|
||||
// 服务端地址:改成你实际部署的域名或 IP。
|
||||
// 内网/开发环境可填 http://<服务器IP>:<端口>(例如 http://192.168.1.100:80)。
|
||||
const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
|
||||
// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
|
||||
const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
|
||||
|
||||
const val PICK_VIDEO_REQUEST = 1001
|
||||
const val SAMPLE_ID = "online_sample_01"
|
||||
const val RECORD_AUDIO_REQUEST = 1002
|
||||
const val PROGRESS_MAX = 1000
|
||||
|
||||
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
enum class Module {
|
||||
TRAIN,
|
||||
TEST,
|
||||
}
|
||||
|
||||
val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)
|
||||
val COLOR_SURFACE: Int = Color.rgb(28, 34, 40)
|
||||
val COLOR_BORDER: Int = Color.rgb(50, 59, 67)
|
||||
val COLOR_BUTTON: Int = Color.rgb(37, 99, 235)
|
||||
val COLOR_ACCENT: Int = Color.rgb(21, 184, 132)
|
||||
val COLOR_ACCENT_DEEP: Int = Color.rgb(7, 118, 86)
|
||||
val COLOR_ACCENT_LIGHT: Int = Color.rgb(94, 234, 182)
|
||||
val COLOR_SELECTED: Int = Color.rgb(229, 248, 240)
|
||||
val COLOR_PROGRESS_TRACK: Int = Color.rgb(224, 231, 235)
|
||||
val COLOR_PROGRESS_TRACK_DARK: Int = Color.rgb(55, 62, 70)
|
||||
val COLOR_LIGHT_BORDER: Int = Color.rgb(218, 226, 232)
|
||||
val COLOR_OVERLAY: Int = Color.argb(178, 9, 12, 16)
|
||||
val COLOR_TEXT_DARK: Int = Color.rgb(18, 24, 31)
|
||||
val COLOR_TEXT_MUTED: Int = Color.rgb(151, 162, 174)
|
||||
val COLOR_TEXT_SUBTLE: Int = Color.rgb(88, 98, 108)
|
||||
val COLOR_TEXT_OVERLAY: Int = Color.rgb(163, 172, 182)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M16.48,2.52c3,2.02 5.02,5.27 5.02,9.48 0,1.77 -0.38,3.45 -1.06,4.97L10.02,2.94C11.39,2.51 12.86,2.28 14.4,2.28c0.74,0 1.47,0.08 2.08,0.24zM10.02,21.06L1.56,7.43C0.56,8.82 0,10.36 0,12c0,4.97 4.03,9 9,9h0.51C9.73,21.06 9.87,21.06 10.02,21.06zM9,22.06C3.96,22.06 -0.03,18.08 -0.03,13.04c0,-1.6 0.39,-3.11 1.07,-4.44l8.57,13.12C9.63,21.72 9.6,21.88 9.6,22.06 9.6,22.06 9.6,22.06 9,22.06zM20.93,5.44c0.68,1.33 1.07,2.84 1.07,4.44 0,5.04 -3.99,9.02 -9.03,9.02 -0.2,0 -0.4,-0.01 -0.6,-0.02l8.56,-13.44L20.93,5.44z"/>
|
||||
</vector>
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">口语宝</string>
|
||||
<string name="app_name">跟读虫</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user