beautified some ui

This commit is contained in:
2026-08-17 14:28:37 +08:00
parent 624b834632
commit d27c2e229e
15 changed files with 1023 additions and 200 deletions

View File

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

View File

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

View File

@@ -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,
)

View File

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

View File

@@ -65,43 +65,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()
}
}

View File

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

View File

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