add test module

This commit is contained in:
2026-08-16 15:39:52 +08:00
parent d0310620fc
commit 6e4d93cea6
46 changed files with 3880 additions and 206 deletions

View File

@@ -2,7 +2,7 @@
Android SDK for online oral-training video playback. It provides an embeddable
Media3/ExoPlayer player, streaming cache, tablet gestures, sentence navigation,
and a placeholder interface for future imitation-quality scoring.
the server video catalog, and remote imitation-quality assessment.
## Modules
@@ -93,11 +93,36 @@ Override the service only when a staging or private deployment is required:
OralTrainerSdk.init(
context,
OralTrainerSdkConfig(
sentenceBoundaryApiBaseUrl = "https://video_service.d1kt.cn"
sentenceBoundaryApiBaseUrl = "https://video_service.d1kt.cn",
assessmentApiKey = BuildConfig.ORAL_TRAINER_CLIENT_KEY,
)
)
```
## Video Catalog
The SDK loads ready videos from `GET /api/v1/videos` and resolves both absolute
and relative stream URLs:
```kotlin
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
override fun onSuccess(videos: List<TrainingVideoSummary>) {
val video = videos.first()
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
override fun onSuccess(result: SentenceBoundaryApiResult) {
controller.loadItem(video.toTrainingMediaItem(result.sentences))
}
override fun onError(error: Throwable) {
controller.loadItem(video.toTrainingMediaItem())
}
})
}
override fun onError(error: Throwable) = Unit
})
```
## Local Video Testing
The computer path `/Users/...` is not visible to an Android device. For a
@@ -110,19 +135,40 @@ service and pass its URL as `TrainingMediaItem.uri`. The SDK streams it and
caches downloaded ranges locally. Copying large 4K files to each device is
better reserved for explicitly offline courses.
## Future Imitation Scoring
## Remote Imitation Scoring
Provide an implementation of `ImitationQualityAssessor` when the speech
assessment algorithm is ready:
Create the controller with the built-in remote assessor:
```kotlin
val remoteAssessor = sdk.createRemoteImitationQualityAssessor()
val controller = sdk.createController(
imitationAssessor = MyImitationQualityAssessor()
imitationAssessor = remoteAssessor
)
```
Then call `assessCurrentSentence(recordingUri, callback)` after the student
records a sentence.
records a sentence. The current `TrainingMediaItem.id` must be the server video
SHA-256, which is already true for items created by `TrainingVideoSummary`:
```kotlin
controller.assessCurrentSentence(
recordingUri = recordingUri,
locale = "en",
callback = object : ImitationAssessmentCallback {
override fun onResult(result: ImitationAssessmentResult) {
val passed = result.passed == true
val score = result.overallScore
val durationRatio = result.durationRatio
}
override fun onError(error: Throwable) = Unit
}
)
```
The server returns content, completeness, fluency, duration, pause, and speech
rate scores. Phoneme pronunciation and prosody are nullable until the dedicated
models are enabled.
## Build

View File

@@ -18,6 +18,17 @@ data class ImitationAssessmentResult @JvmOverloads constructor(
val completenessScore: Float? = null,
val feedback: String? = null,
val details: Map<String, String> = emptyMap(),
val passed: Boolean? = null,
val contentScore: Float? = null,
val durationScore: Float? = null,
val pauseScore: Float? = null,
val speechRateScore: Float? = null,
val durationRatio: Float? = null,
val referenceText: String? = null,
val recognizedText: String? = null,
val missingTokens: List<String> = emptyList(),
val extraTokens: List<String> = emptyList(),
val substitutions: List<Pair<String, String>> = emptyList(),
)
fun interface CancellableAssessment {

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.widget.FrameLayout
import androidx.media3.ui.PlayerView
import kotlin.math.abs
@@ -21,6 +22,23 @@ class OralTrainerPlayerView @JvmOverloads constructor(
private var controller: OralTrainerController? = null
private var gestureControls = GestureControlsConfig()
private val density = resources.displayMetrics.density
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private val longPressTimeoutMs = ViewConfiguration.getLongPressTimeout().toLong()
private var longPressStartX = 0f
private var longPressStartY = 0f
private var longPressActive = false
private var speedBeforeLongPress: Float? = null
private val longPressSpeedRunnable = Runnable {
val activeController = controller ?: return@Runnable
if (!gestureControls.enabled || !gestureControls.longPressAdjustsSpeed || width <= 0) {
return@Runnable
}
longPressActive = true
speedBeforeLongPress = activeController.snapshot().playbackSpeed
activeController.setPlaybackSpeed(speedForTouchX(longPressStartX))
activeController.dispatchGesture(GestureEvent(GestureKind.LONG_PRESS_SPEED))
}
private val gestureDetector = GestureDetector(
context,
@@ -99,6 +117,72 @@ class OralTrainerPlayerView @JvmOverloads constructor(
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (handleLongPressSpeed(ev)) {
return true
}
return gestureDetector.onTouchEvent(ev) || super.dispatchTouchEvent(ev)
}
override fun onDetachedFromWindow() {
cancelLongPressSpeed(restoreSpeed = true)
super.onDetachedFromWindow()
}
private fun handleLongPressSpeed(event: MotionEvent): Boolean {
if (!gestureControls.enabled || !gestureControls.longPressAdjustsSpeed) {
return false
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
longPressStartX = event.x
longPressStartY = event.y
longPressActive = false
speedBeforeLongPress = null
removeCallbacks(longPressSpeedRunnable)
postDelayed(longPressSpeedRunnable, longPressTimeoutMs)
return false
}
MotionEvent.ACTION_MOVE -> {
if (longPressActive) {
controller?.setPlaybackSpeed(speedForTouchX(event.x))
return true
}
val dx = abs(event.x - longPressStartX)
val dy = abs(event.y - longPressStartY)
if (dx > touchSlop || dy > touchSlop) {
removeCallbacks(longPressSpeedRunnable)
}
return false
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
val wasActive = longPressActive
cancelLongPressSpeed(restoreSpeed = wasActive)
return wasActive
}
}
return false
}
private fun cancelLongPressSpeed(restoreSpeed: Boolean) {
removeCallbacks(longPressSpeedRunnable)
if (restoreSpeed) {
controller?.setPlaybackSpeed(speedBeforeLongPress ?: 1f)
}
longPressActive = false
speedBeforeLongPress = null
}
private fun speedForTouchX(x: Float): Float {
val center = width / 2f
val distanceRatio = if (center <= 0f) 0f else abs(x - center) / center
val farFromCenter = distanceRatio >= 0.5f
return if (x < center) {
if (farFromCenter) gestureControls.longPressSlowerSpeed else gestureControls.longPressSlowSpeed
} else {
if (farFromCenter) gestureControls.longPressFasterSpeed else gestureControls.longPressFastSpeed
}
}
}

View File

@@ -1,6 +1,7 @@
package cn.learningpad.oraltrainer.sdk
import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
class OralTrainerSdk private constructor(
context: Context,
@@ -10,6 +11,12 @@ class OralTrainerSdk private constructor(
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
fun createRemoteImitationQualityAssessor(): RemoteImitationQualityAssessor {
return RemoteImitationQualityAssessor(appContext, config).also(remoteAssessors::add)
}
@JvmOverloads
fun createController(
@@ -26,6 +33,9 @@ class OralTrainerSdk private constructor(
fun release() {
sentenceBoundaryApi.release()
videoCatalogApi.release()
remoteAssessors.forEach { it.release() }
remoteAssessors.clear()
StreamingCache.release()
}

View File

@@ -9,4 +9,5 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
val connectTimeoutMs: Int = 15_000,
val readTimeoutMs: Int = 30_000,
val sentenceBoundaryApiBaseUrl: String = "https://video_service.d1kt.cn",
val assessmentApiKey: String? = null,
)

View File

@@ -27,6 +27,7 @@ enum class GestureKind {
SINGLE_TAP,
SWIPE_LEFT,
SWIPE_RIGHT,
LONG_PRESS_SPEED,
}
interface OralTrainerListener {

View File

@@ -16,6 +16,11 @@ data class GestureControlsConfig @JvmOverloads constructor(
val rightSwipeAction: SwipeAction = SwipeAction.NEXT_SENTENCE_OR_FORWARD,
val minSwipeDistanceDp: Float = 48f,
val minSwipeVelocityDpPerSecond: Float = 160f,
val longPressAdjustsSpeed: Boolean = true,
val longPressSlowSpeed: Float = 0.75f,
val longPressSlowerSpeed: Float = 0.5f,
val longPressFastSpeed: Float = 1.5f,
val longPressFasterSpeed: Float = 2.0f,
)
enum class SwipeAction {

View File

@@ -0,0 +1,198 @@
package cn.learningpad.oraltrainer.sdk
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
/** Uploads one sentence recording to the server-side MOSS assessment endpoint. */
class RemoteImitationQualityAssessor internal constructor(
context: Context,
private val config: OralTrainerSdkConfig,
) : ImitationQualityAssessor {
private val appContext = context.applicationContext
private val contentResolver: ContentResolver = appContext.contentResolver
private val executor: ExecutorService = Executors.newCachedThreadPool()
private val mainHandler = Handler(Looper.getMainLooper())
private val baseUri = Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
init {
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
"sentenceBoundaryApiBaseUrl must use http or https."
}
require(!baseUri.host.isNullOrBlank()) {
"sentenceBoundaryApiBaseUrl must include a host."
}
}
override fun assess(
request: ImitationAssessmentRequest,
callback: ImitationAssessmentCallback,
): CancellableAssessment {
val future = executor.submit {
try {
val result = assessBlocking(request)
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onResult(result) }
}
} catch (error: Throwable) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onError(error) }
}
}
}
return CancellableAssessment { future.cancel(true) }
}
fun release() {
executor.shutdownNow()
}
private fun assessBlocking(request: ImitationAssessmentRequest): ImitationAssessmentResult {
val videoHash = request.metadata["video_hash"] ?: request.mediaId
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)
}
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()
}
}
private fun writeFormField(output: BufferedOutputStream, boundary: String, name: String, value: String) {
output.write("--$boundary\r\n".toByteArray(Charsets.UTF_8))
output.write("Content-Disposition: form-data; name=\"$name\"\r\n\r\n".toByteArray(Charsets.UTF_8))
output.write(value.toByteArray(Charsets.UTF_8))
output.write("\r\n".toByteArray(Charsets.UTF_8))
}
private fun writeFilePart(output: BufferedOutputStream, boundary: String, uri: Uri) {
val fileName = uri.lastPathSegment?.substringAfterLast('/')
?.replace("\r", "_")
?.replace("\n", "_")
?.replace("\"", "_")
?.ifBlank { "recording.wav" }
?: "recording.wav"
val mimeType = contentResolver.getType(uri) ?: "audio/wav"
output.write("--$boundary\r\n".toByteArray(Charsets.UTF_8))
output.write(
"Content-Disposition: form-data; name=\"audio\"; filename=\"$fileName\"\r\n".toByteArray(
Charsets.UTF_8
)
)
output.write("Content-Type: $mimeType\r\n\r\n".toByteArray(Charsets.UTF_8))
contentResolver.openInputStream(uri)?.use { input ->
BufferedInputStream(input).use { buffered ->
val buffer = ByteArray(64 * 1024)
while (true) {
if (Thread.currentThread().isInterrupted) throw IOException("Assessment request was cancelled.")
val count = buffered.read(buffer)
if (count < 0) break
output.write(buffer, 0, count)
}
}
} ?: throw IOException("Cannot open recording URI: $uri")
output.write("\r\n".toByteArray(Charsets.UTF_8))
}
private fun parseResponse(body: String): ImitationAssessmentResult {
val root = JSONObject(body)
val substitutions = buildList {
val array = root.optJSONArray("substitutions") ?: JSONArray()
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.getDouble("overall_score").toFloat(),
pronunciationScore = root.optNullableDouble("pronunciation_score")?.toFloat(),
fluencyScore = root.optNullableDouble("fluency_score")?.toFloat(),
completenessScore = root.optNullableDouble("completeness_score")?.toFloat(),
feedback = root.optString("feedback").ifBlank { null },
details = details,
passed = if (root.has("passed")) root.optBoolean("passed") else null,
contentScore = root.optNullableDouble("content_score")?.toFloat(),
durationScore = root.optNullableDouble("duration_score")?.toFloat(),
pauseScore = root.optNullableDouble("pause_score")?.toFloat(),
speechRateScore = root.optNullableDouble("speech_rate_score")?.toFloat(),
durationRatio = root.optNullableDouble("duration_ratio")?.toFloat(),
referenceText = root.optString("reference_text").ifBlank { null },
recognizedText = root.optString("recognized_text").ifBlank { null },
missingTokens = root.optStringArray("missing_tokens"),
extraTokens = root.optStringArray("extra_tokens"),
substitutions = substitutions,
)
}
companion object {
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
}
}
class RemoteAssessmentException(
val statusCode: Int,
responseBody: String,
) : IOException("Remote assessment returned HTTP $statusCode: $responseBody")
private fun JSONObject.optNullableDouble(name: String): Double? {
if (!has(name) || isNull(name)) return null
return optDouble(name).takeUnless { it.isNaN() }
}
private fun JSONObject.optStringArray(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
return buildList(array.length()) {
for (index in 0 until array.length()) add(array.optString(index))
}
}

View File

@@ -147,6 +147,12 @@ class SentenceBoundaryApi internal constructor(
startMs = sentence.getLong("start_ms"),
endMs = sentence.getLong("end_ms"),
text = if (sentence.isNull("text")) null else sentence.getString("text"),
language = if (sentence.isNull("language")) null else sentence.getString("language"),
referenceSpeechDurationMs = if (sentence.isNull("reference_speech_duration_ms")) {
null
} else {
sentence.getLong("reference_speech_duration_ms")
},
)
)
}

View File

@@ -8,11 +8,16 @@ data class SentenceBoundary @JvmOverloads constructor(
val startMs: Long,
val endMs: Long,
val text: String? = null,
val language: String? = null,
val referenceSpeechDurationMs: Long? = null,
) {
init {
require(index >= 0) { "Sentence index must be non-negative." }
require(startMs >= 0) { "Sentence startMs must be non-negative." }
require(endMs > startMs) { "Sentence endMs must be greater than startMs." }
require(referenceSpeechDurationMs == null || referenceSpeechDurationMs > 0) {
"Sentence referenceSpeechDurationMs must be positive when present."
}
}
}

View File

@@ -0,0 +1,116 @@
package cn.learningpad.oraltrainer.sdk
import android.content.Context
import android.net.Uri
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
data class TrainingVideoSummary(
val videoHash: String,
val title: String,
val streamUri: Uri,
val durationMs: Long?,
val sizeBytes: Long,
val language: String?,
val sentenceCount: Int,
val status: String,
) {
fun toTrainingMediaItem(sentences: List<SentenceBoundary> = emptyList()): TrainingMediaItem {
return TrainingMediaItem(
id = videoHash,
title = title,
uri = streamUri,
sentences = sentences,
customCacheKey = videoHash,
)
}
}
interface VideoCatalogCallback {
fun onSuccess(videos: List<TrainingVideoSummary>)
fun onError(error: Throwable)
}
class VideoCatalogApi internal constructor(
context: Context,
private val config: OralTrainerSdkConfig,
) {
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 {
try {
val videos = fetchBlocking()
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onSuccess(videos) }
}
} catch (error: Throwable) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onError(error) }
}
}
}
return CancellableRequest(future)
}
fun release() {
executor.shutdownNow()
}
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()
}
}
private fun parseResponse(body: String): List<TrainingVideoSummary> {
val videos = JSONObject(body).getJSONArray("videos")
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"))
add(
TrainingVideoSummary(
videoHash = item.getString("video_hash"),
title = item.getString("title"),
streamUri = Uri.parse(streamUrl.toString()),
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"),
sentenceCount = item.getInt("sentence_count"),
status = status,
)
)
}
}
}
}
class VideoCatalogApiException(
val statusCode: Int,
responseBody: String,
) : IOException("Video catalog API returned HTTP $statusCode: $responseBody")

View File

@@ -3,7 +3,7 @@
<application
android:allowBackup="true"
android:label="Oral Trainer Sample"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity

View File

@@ -2,16 +2,24 @@ package cn.learningpad.oraltrainer.sample
import android.app.Activity
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ProgressBar
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.LoopMode
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.OralTrainerListener
@@ -21,21 +29,33 @@ import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
import cn.learningpad.oraltrainer.sdk.PlayerConfig
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
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.util.Locale
class MainActivity : Activity() {
private companion object {
const val PICK_VIDEO_REQUEST = 1001
}
private lateinit var sdk: OralTrainerSdk
private lateinit var controller: OralTrainerController
private lateinit var playerView: OralTrainerPlayerView
private lateinit var lessonTitleText: TextView
private lateinit var statusText: TextView
private lateinit var sentenceText: TextView
private lateinit var sentenceMetaText: TextView
private lateinit var timeText: TextView
private lateinit var speedText: TextView
private lateinit var progressBar: ProgressBar
private lateinit var catalogList: LinearLayout
private lateinit var catalogStatusText: TextView
private var activeItemId: String = SAMPLE_ID
private var currentSentenceCount = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configureWindow()
sdk = OralTrainerSdk.init(this)
controller = sdk.createController(
@@ -47,77 +67,10 @@ class MainActivity : Activity() {
)
controller.setLoopMode(LoopMode.ALL)
val playerView = OralTrainerPlayerView(this).apply {
bind(controller)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
}
statusText = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 14f
setPadding(24, 16, 24, 8)
}
sentenceText = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 18f
setPadding(24, 4, 24, 16)
}
val controls = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
setPadding(16, 8, 16, 20)
addView(commandButton("快退") { controller.rewind() })
addView(commandButton("播放/暂停") { controller.togglePlayPause() })
addView(commandButton("快进") { controller.fastForward() })
addView(commandButton("上一句") { controller.previousSentenceOrRewind() })
addView(commandButton("下一句") { controller.nextSentenceOrForward() })
}
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.rgb(18, 18, 18))
addView(playerView)
addView(statusText)
addView(sentenceText)
addView(controls)
addView(Button(this@MainActivity).apply {
text = "选择本地视频"
setOnClickListener { openLocalVideoPicker() }
})
}
setContentView(root)
controller.addListener(object : OralTrainerListener {
override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
statusText.text = buildString {
append(if (snapshot.isPlaying) "播放中" else "已暂停")
append(" ")
append(format(snapshot.positionMs))
append(" / ")
append(format(snapshot.durationMs))
append(" 速度 ")
append(snapshot.playbackSpeed)
append("x")
}
}
override fun onSentenceChanged(sentence: SentenceBoundary?) {
sentenceText.text = sentence?.let {
"${it.index + 1} 句:${it.text.orEmpty()}"
} ?: "当前没有句子边界"
}
override fun onGesture(event: GestureEvent) {
statusText.text = "手势:${event.kind} ${event.action ?: ""}"
}
})
controller.loadItem(sampleOnlineLesson())
setContentView(createContentView())
bindPlayerEvents()
loadSampleLesson()
loadCatalog()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -132,21 +85,38 @@ class MainActivity : Activity() {
contentResolver.takePersistableUriPermission(uri, persistableFlags)
}
}
val item = TrainingMediaItem(
id = uri.toString(),
title = uri.lastPathSegment ?: "本地视频",
uri = uri,
)
activeItemId = item.id
currentSentenceCount = 0
controller.loadItem(item)
statusText.text = "正在获取句子边界..."
lessonTitleText.text = item.title
sentenceMetaText.text = "句子边界分析中"
sentenceText.text = "正在匹配服务端句子边界"
statusText.text = "正在获取本地视频的句子边界..."
renderCatalog()
sdk.sentenceBoundaryApi.fetchForUri(uri, contentResolver, object : SentenceBoundaryApiCallback {
override fun onSuccess(result: SentenceBoundaryApiResult) {
if (activeItemId != item.id) {
return
}
currentSentenceCount = result.sentences.size
controller.loadItem(item.copy(sentences = result.sentences))
statusText.text = "句子边界已加载:${result.sentences.size}"
}
override fun onError(error: Throwable) {
statusText.text = "句子边界获取失败,使用 10 秒跳转:${error.message.orEmpty()}"
if (activeItemId != item.id) {
return
}
statusText.text = "句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}"
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = item.title
}
})
}
@@ -156,18 +126,381 @@ class MainActivity : Activity() {
super.onDestroy()
}
private fun commandButton(label: String, action: () -> Unit): Button {
@Suppress("DEPRECATION")
private fun configureWindow() {
window.statusBarColor = COLOR_BACKGROUND
window.navigationBarColor = COLOR_BACKGROUND
}
private fun createContentView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createHeader())
addView(createPlayerSection())
addView(createSentenceSection())
addView(createCatalogSection())
}
}
private fun createHeader(): View {
val titleBlock = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(this@MainActivity).apply {
text = "口语宝"
setTextColor(Color.WHITE)
textSize = 26f
typeface = Typeface.DEFAULT_BOLD
includeFontPadding = false
})
addView(TextView(this@MainActivity).apply {
text = "中英跟读训练"
setTextColor(COLOR_TEXT_MUTED)
textSize = 13f
setPadding(1.dp, 4.dp, 0, 0)
})
}
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(18.dp, 16.dp, 18.dp, 12.dp)
addView(titleBlock)
addView(headerButton("导入") { openLocalVideoPicker() })
addView(headerButton("刷新") { loadCatalog() })
}
}
private fun createPlayerSection(): View {
playerView = OralTrainerPlayerView(this).apply {
bind(controller)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
val overlay = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(12.dp, 12.dp, 12.dp, 12.dp)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
)
}
timeText = overlayPill("--:-- / --:--").apply {
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
speedText = overlayPill("1x")
overlay.addView(timeText)
overlay.addView(speedText)
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 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
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)
addView(lessonTitleText)
addView(sentenceMetaText)
addView(sentenceText)
addView(progressBar)
addView(statusText)
}
}
private fun createCatalogSection(): View {
catalogStatusText = TextView(this).apply {
setTextColor(COLOR_TEXT_MUTED)
textSize = 13f
gravity = Gravity.END
text = "正在同步"
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
catalogList = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 10.dp, 0, 4.dp)
}
val header = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(TextView(this@MainActivity).apply {
text = "课程"
setTextColor(Color.WHITE)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
})
addView(catalogStatusText)
}
val scroller = HorizontalScrollView(this).apply {
isHorizontalScrollBarEnabled = false
addView(catalogList)
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 0, 16.dp, 14.dp)
addView(header)
addView(scroller)
}
}
private fun bindPlayerEvents() {
controller.addListener(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)
}
override fun onMediaChanged(item: TrainingMediaItem?) {
lessonTitleText.text = item?.title ?: "未选择课程"
currentSentenceCount = item?.sentences?.size ?: 0
}
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() } ?: "当前句子"
}
override fun onGesture(event: GestureEvent) {
statusText.text = when (event.kind) {
GestureKind.SINGLE_TAP -> "播放状态已切换"
GestureKind.SWIPE_LEFT -> "已跳到上一句"
GestureKind.SWIPE_RIGHT -> "已跳到下一句"
GestureKind.LONG_PRESS_SPEED -> "变速播放中"
}
}
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 loadCatalog() {
catalogStatusText.text = "正在同步"
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
override fun onSuccess(videos: List<TrainingVideoSummary>) {
catalogVideos = videos
catalogStatusText.text = if (videos.isEmpty()) {
"暂无云端课程"
} else {
"${videos.size} 个云端课程"
}
renderCatalog()
}
override fun onError(error: Throwable) {
catalogStatusText.text = "云端暂不可用"
renderCatalog()
}
})
}
private fun renderCatalog() {
if (!::catalogList.isInitialized) {
return
}
catalogList.removeAllViews()
catalogList.addView(
videoCard(
id = SAMPLE_ID,
title = "示例课程",
meta = "4 句 · ${formatTime(16_000L)}",
) {
loadSampleLesson()
}
)
catalogVideos.forEach { video ->
catalogList.addView(
videoCard(
id = video.videoHash,
title = video.title,
meta = "${video.sentenceCount} 句 · ${formatTime(video.durationMs ?: -1L)}",
) {
loadRemoteVideo(video)
}
)
}
}
private fun loadRemoteVideo(video: TrainingVideoSummary) {
activeItemId = video.videoHash
lessonTitleText.text = video.title
sentenceMetaText.text = "句子边界加载中"
sentenceText.text = video.title
statusText.text = "正在加载云端课程..."
renderCatalog()
val loadingItem = video.toTrainingMediaItem()
controller.loadItem(loadingItem)
currentSentenceCount = video.sentenceCount
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
override fun onSuccess(result: SentenceBoundaryApiResult) {
if (activeItemId != video.videoHash) {
return
}
currentSentenceCount = result.sentences.size
controller.loadItem(video.toTrainingMediaItem(result.sentences))
statusText.text = "课程已就绪:${result.sentences.size}"
}
override fun onError(error: Throwable) {
if (activeItemId != video.videoHash) {
return
}
statusText.text = "句子边界加载失败:${error.message.orEmpty()}"
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = video.title
}
})
}
private fun videoCard(
id: String,
title: String,
meta: String,
onClick: () -> Unit,
): View {
val selected = activeItemId == id
val titleColor = if (selected) COLOR_TEXT_DARK else Color.WHITE
val metaColor = if (selected) COLOR_ACCENT_DEEP else COLOR_TEXT_MUTED
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
isClickable = true
background = if (selected) {
rounded(COLOR_SELECTED, 8f, COLOR_ACCENT)
} else {
rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
}
setPadding(14.dp, 12.dp, 14.dp, 12.dp)
layoutParams = LinearLayout.LayoutParams(218.dp, 84.dp).withMargins(0, 0, 10.dp, 0)
setOnClickListener { onClick() }
addView(TextView(this@MainActivity).apply {
text = title
setTextColor(titleColor)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
maxLines = 2
})
addView(TextView(this@MainActivity).apply {
text = meta
setTextColor(metaColor)
textSize = 12f
setPadding(0, 6.dp, 0, 0)
})
}
}
private fun headerButton(label: String, action: () -> Unit): Button {
return Button(this).apply {
text = label
setOnClickListener { action() }
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(COLOR_BUTTON, 8f)
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f,
).apply {
marginStart = 4
marginEnd = 4
}
40.dp,
).withMargins(8.dp, 0, 0, 0)
setOnClickListener { action() }
}
}
private fun overlayPill(label: String): TextView {
return TextView(this).apply {
text = label
setTextColor(Color.WHITE)
textSize = 13f
typeface = Typeface.DEFAULT_BOLD
background = rounded(COLOR_OVERLAY, 8f)
setPadding(10.dp, 6.dp, 10.dp, 6.dp)
gravity = Gravity.CENTER
}
}
@@ -185,27 +518,103 @@ class MainActivity : Activity() {
private fun sampleOnlineLesson(): TrainingMediaItem {
return TrainingMediaItem(
id = "online_sample_01",
title = "Online Sample Lesson",
id = SAMPLE_ID,
title = "口语宝示例课",
uri = Uri.parse("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
mimeType = MimeTypes.VIDEO_MP4,
customCacheKey = "online_sample_01",
customCacheKey = SAMPLE_ID,
sentences = listOf(
SentenceBoundary(0, 0L, 3_000L, "Listen once, then imitate."),
SentenceBoundary(1, 3_000L, 7_000L, "Swipe left to go back."),
SentenceBoundary(2, 7_000L, 11_000L, "Swipe right to move forward."),
SentenceBoundary(3, 11_000L, 16_000L, "Tap the video area to pause or play."),
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 format(ms: Long): String {
private fun playbackStatus(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"
}
private fun playbackProgress(snapshot: PlaybackSnapshot): Int {
if (snapshot.durationMs <= 0L) {
return 0
}
return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * PROGRESS_MAX) / snapshot.durationMs).toInt()
}
private fun formatTime(ms: Long): String {
if (ms < 0) {
return "--:--"
}
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return "%02d:%02d".format(minutes, seconds)
return if (hours > 0) {
"%d:%02d:%02d".format(Locale.US, hours, minutes, seconds)
} else {
"%02d:%02d".format(Locale.US, minutes, seconds)
}
}
private fun formatSpeed(speed: Float): String {
val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.')
return "${value}x"
}
private fun rounded(
color: Int,
radiusDp: Float,
strokeColor: Int? = null,
): GradientDrawable {
return GradientDrawable().apply {
setColor(color)
cornerRadius = radiusDp.dp.toFloat()
strokeColor?.let { setStroke(1.dp, it) }
}
}
private fun LinearLayout.LayoutParams.withMargins(
left: Int,
top: Int,
right: Int,
bottom: Int,
): LinearLayout.LayoutParams {
setMargins(left, top, right, bottom)
return this
}
private val Int.dp: Int
get() = (this * resources.displayMetrics.density).toInt()
private val Float.dp: Int
get() = (this * resources.displayMetrics.density).toInt()
private companion object {
const val PICK_VIDEO_REQUEST = 1001
const val SAMPLE_ID = "online_sample_01"
const val PROGRESS_MAX = 1000
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_SELECTED: Int = Color.rgb(229, 248, 240)
val COLOR_PROGRESS_TRACK: Int = Color.rgb(224, 231, 235)
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)
}
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">口语宝</string>
</resources>