beautified some ui
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package cn.learningpad.oraltrainer.sdk
|
||||
|
||||
import android.net.Uri
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Runs one blocking request against [baseUrl]. When [OralTrainerSdkConfig.allowHttpFallback]
|
||||
* is enabled and the HTTPS attempt fails with a TLS error (broken certificates or
|
||||
* misconfigured TLS termination at the gateway), retries the same request over plain HTTP.
|
||||
* This is intended for private deployments; production should fix the TLS edge instead.
|
||||
*/
|
||||
internal class HttpFallback private constructor(
|
||||
private val allowFallback: Boolean,
|
||||
) {
|
||||
fun <T> execute(baseUrl: String, block: (String) -> T): T {
|
||||
try {
|
||||
return block(baseUrl)
|
||||
} catch (error: Throwable) {
|
||||
if (!allowFallback || !isTlsFailure(error)) {
|
||||
throw error
|
||||
}
|
||||
val uri = Uri.parse(baseUrl)
|
||||
if (uri.scheme != "https") {
|
||||
throw error
|
||||
}
|
||||
val httpUrl = uri.buildUpon().scheme("http").build().toString()
|
||||
return block(httpUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTlsFailure(error: Throwable): Boolean {
|
||||
var current: Throwable? = error
|
||||
while (current != null) {
|
||||
val message = current.message.orEmpty()
|
||||
if (current is SSLException || message.contains("SSL", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun forConfig(config: OralTrainerSdkConfig): HttpFallback {
|
||||
return HttpFallback(config.allowHttpFallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class OralTrainerController internal constructor(
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var released = false
|
||||
private var lastSentenceIndex: Int? = null
|
||||
private var stopAtSentenceEnd = false
|
||||
private var stopAtSentenceEndIndex: Int? = null
|
||||
|
||||
var config: PlayerConfig = initialConfig
|
||||
private set
|
||||
@@ -42,6 +44,7 @@ class OralTrainerController internal constructor(
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
enforceSentenceBoundary()
|
||||
notifySentenceIfChanged()
|
||||
notifySnapshot()
|
||||
mainHandler.postDelayed(this, 250L)
|
||||
@@ -124,6 +127,8 @@ class OralTrainerController internal constructor(
|
||||
mediaItems.clear()
|
||||
mediaItems.addAll(items)
|
||||
lastSentenceIndex = null
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs))
|
||||
player.prepare()
|
||||
player.playWhenReady = config.autoPlay
|
||||
@@ -141,6 +146,8 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.pause()
|
||||
}
|
||||
|
||||
@@ -153,10 +160,14 @@ class OralTrainerController internal constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.stop()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.seekTo(max(0L, positionMs))
|
||||
}
|
||||
|
||||
@@ -202,6 +213,18 @@ class OralTrainerController internal constructor(
|
||||
config = config.copy(sentenceMode = enabled)
|
||||
}
|
||||
|
||||
fun setContinuousPlayback(enabled: Boolean) {
|
||||
config = config.copy(continuousPlayback = enabled)
|
||||
}
|
||||
|
||||
fun playCurrentSentenceAndStop() {
|
||||
val sentence = currentSentence() ?: return
|
||||
player.seekTo(sentence.startMs)
|
||||
stopAtSentenceEnd = true
|
||||
stopAtSentenceEndIndex = sentence.index
|
||||
player.play()
|
||||
}
|
||||
|
||||
fun currentTrainingItem(): TrainingMediaItem? {
|
||||
val index = player.currentMediaItemIndex
|
||||
return mediaItems.getOrNull(index)
|
||||
@@ -335,6 +358,36 @@ class OralTrainerController internal constructor(
|
||||
listeners.forEach { it.onSentenceChanged(sentence) }
|
||||
}
|
||||
|
||||
private fun enforceSentenceBoundary() {
|
||||
if (!config.sentenceMode || !player.isPlaying) {
|
||||
return
|
||||
}
|
||||
val item = currentTrainingItem() ?: return
|
||||
if (item.sentences.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val position = player.currentPosition
|
||||
if (stopAtSentenceEnd) {
|
||||
val index = stopAtSentenceEndIndex ?: return
|
||||
val sentence = item.sentences.getOrNull(index) ?: return
|
||||
if (position >= sentence.endMs) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = null
|
||||
player.pause()
|
||||
player.seekTo(sentence.startMs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (config.continuousPlayback) {
|
||||
return
|
||||
}
|
||||
val anchorIndex = lastSentenceIndex ?: return
|
||||
val anchor = item.sentences.getOrNull(anchorIndex) ?: return
|
||||
if (position >= anchor.endMs) {
|
||||
player.seekTo(anchor.startMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TrainingMediaItem.toMedia3Item(): MediaItem {
|
||||
val builder = MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
|
||||
@@ -10,4 +10,5 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
|
||||
val readTimeoutMs: Int = 30_000,
|
||||
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
||||
val assessmentApiKey: String? = null,
|
||||
val allowHttpFallback: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ data class PlayerConfig @JvmOverloads constructor(
|
||||
val sentenceMode: Boolean = true,
|
||||
val defaultSeekStepMs: Long = 10_000L,
|
||||
val autoPlay: Boolean = false,
|
||||
val continuousPlayback: Boolean = true,
|
||||
val minPlaybackSpeed: Float = 0.5f,
|
||||
val maxPlaybackSpeed: Float = 2.0f,
|
||||
val gestureControls: GestureControlsConfig = GestureControlsConfig(),
|
||||
|
||||
@@ -65,43 +65,45 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
require(SHA256_PATTERN.matches(videoHash)) {
|
||||
"ImitationAssessmentRequest.mediaId or metadata[video_hash] must be a SHA-256 hash."
|
||||
}
|
||||
val endpoint = baseUri.buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash.lowercase())
|
||||
.appendPath("sentences")
|
||||
.appendPath(request.sentence.index.toString())
|
||||
.appendPath("assessments")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
val boundary = "----OralTrainer-${UUID.randomUUID()}"
|
||||
return try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = maxOf(config.readTimeoutMs, 180_000)
|
||||
connection.setChunkedStreamingMode(64 * 1024)
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash.lowercase())
|
||||
.appendPath("sentences")
|
||||
.appendPath(request.sentence.index.toString())
|
||||
.appendPath("assessments")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
val boundary = "----OralTrainer-${UUID.randomUUID()}"
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = maxOf(config.readTimeoutMs, 180_000)
|
||||
connection.setChunkedStreamingMode(64 * 1024)
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
output.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw RemoteAssessmentException(statusCode, body)
|
||||
}
|
||||
parseResponse(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
output.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw RemoteAssessmentException(statusCode, body)
|
||||
}
|
||||
parseResponse(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,33 +98,35 @@ class SentenceBoundaryApi internal constructor(
|
||||
}
|
||||
|
||||
private fun fetchBlocking(videoHash: String): SentenceBoundaryApiResult {
|
||||
val endpoint = baseUri.buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash)
|
||||
.appendPath("sentence-boundaries")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = Uri.parse(effectiveBaseUrl).buildUpon()
|
||||
.appendPath("api")
|
||||
.appendPath("v1")
|
||||
.appendPath("videos")
|
||||
.appendPath(videoHash)
|
||||
.appendPath("sentence-boundaries")
|
||||
.build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream
|
||||
}
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw SentenceBoundaryApiException(statusCode, body)
|
||||
}
|
||||
parseResponse(body, videoHash)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) {
|
||||
throw SentenceBoundaryApiException(statusCode, body)
|
||||
}
|
||||
parseResponse(body, videoHash)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ class VideoCatalogApi internal constructor(
|
||||
) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val baseUrl = config.sentenceBoundaryApiBaseUrl.trimEnd('/') + "/"
|
||||
|
||||
fun fetch(callback: VideoCatalogCallback): CancellableRequest {
|
||||
val future = executor.submit {
|
||||
@@ -67,37 +66,45 @@ class VideoCatalogApi internal constructor(
|
||||
}
|
||||
|
||||
private fun fetchBlocking(): List<TrainingVideoSummary> {
|
||||
val endpoint = URL(URL(baseUrl), "api/v1/videos")
|
||||
val connection = endpoint.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw VideoCatalogApiException(statusCode, body)
|
||||
parseResponse(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
return HttpFallback.forConfig(config).execute(config.sentenceBoundaryApiBaseUrl) { effectiveBaseUrl ->
|
||||
val endpoint = URL(URL(effectiveBaseUrl), "api/v1/videos")
|
||||
val connection = endpoint.openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw VideoCatalogApiException(statusCode, body)
|
||||
parseResponse(body, effectiveBaseUrl)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResponse(body: String): List<TrainingVideoSummary> {
|
||||
private fun parseResponse(body: String, effectiveBaseUrl: String): List<TrainingVideoSummary> {
|
||||
val videos = JSONObject(body).getJSONArray("videos")
|
||||
val effectiveScheme = Uri.parse(effectiveBaseUrl).scheme
|
||||
return buildList(videos.length()) {
|
||||
for (index in 0 until videos.length()) {
|
||||
val item = videos.getJSONObject(index)
|
||||
val status = item.getString("status")
|
||||
if (status != "ready") continue
|
||||
val streamUrl = URL(URL(baseUrl), item.getString("stream_url"))
|
||||
val resolvedStreamUrl = URL(URL(effectiveBaseUrl), item.getString("stream_url")).toString()
|
||||
val streamUri = if (effectiveScheme == "http") {
|
||||
Uri.parse(resolvedStreamUrl).buildUpon().scheme("http").build().toString()
|
||||
} else {
|
||||
resolvedStreamUrl
|
||||
}
|
||||
add(
|
||||
TrainingVideoSummary(
|
||||
videoHash = item.getString("video_hash"),
|
||||
title = item.getString("title"),
|
||||
streamUri = Uri.parse(streamUrl.toString()),
|
||||
streamUri = Uri.parse(streamUri),
|
||||
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
|
||||
sizeBytes = item.getLong("size_bytes"),
|
||||
language = if (item.isNull("language")) null else item.getString("language"),
|
||||
|
||||
Reference in New Issue
Block a user