add test module
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ enum class GestureKind {
|
||||
SINGLE_TAP,
|
||||
SWIPE_LEFT,
|
||||
SWIPE_RIGHT,
|
||||
LONG_PRESS_SPEED,
|
||||
}
|
||||
|
||||
interface OralTrainerListener {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
3
android/sample-app/src/main/res/values/strings.xml
Normal file
3
android/sample-app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">口语宝</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user