fix a bug
This commit is contained in:
@@ -258,9 +258,14 @@ class OralTrainerController internal constructor(
|
|||||||
|
|
||||||
fun playCurrentSentenceAndStop() {
|
fun playCurrentSentenceAndStop() {
|
||||||
val sentence = currentSentence() ?: return
|
val sentence = currentSentence() ?: return
|
||||||
player.seekTo(sentence.startMs)
|
playSentenceAndStop(sentence.startMs, sentence.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seek to a known sentence and stop automatically when that sentence ends. */
|
||||||
|
fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) {
|
||||||
stopAtSentenceEnd = true
|
stopAtSentenceEnd = true
|
||||||
stopAtSentenceEndIndex = sentence.index
|
stopAtSentenceEndIndex = sentenceIndex
|
||||||
|
player.seekTo(max(0L, startMs))
|
||||||
player.play()
|
player.play()
|
||||||
refreshSentenceBoundaryGuard()
|
refreshSentenceBoundaryGuard()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ data class UserDubShare(
|
|||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DubShareSegmentDetail(
|
||||||
|
val index: Int,
|
||||||
|
val overallScore: Float,
|
||||||
|
val recognizedText: String,
|
||||||
|
val scoreDetails: Map<String, Float>,
|
||||||
|
val audioUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DubShareDetail(
|
||||||
|
val shareId: String,
|
||||||
|
val videoHash: String,
|
||||||
|
val title: String,
|
||||||
|
val segments: List<DubShareSegmentDetail>,
|
||||||
|
)
|
||||||
|
|
||||||
class UserApiException(
|
class UserApiException(
|
||||||
val statusCode: Int,
|
val statusCode: Int,
|
||||||
responseBody: String,
|
responseBody: String,
|
||||||
@@ -150,6 +165,13 @@ class UserApi internal constructor(
|
|||||||
onComplete: (Result<List<UserDubShare>>) -> Unit,
|
onComplete: (Result<List<UserDubShare>>) -> Unit,
|
||||||
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) }
|
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) }
|
||||||
|
|
||||||
|
fun dubShareDetail(
|
||||||
|
shareId: String,
|
||||||
|
onComplete: (Result<DubShareDetail>) -> Unit,
|
||||||
|
): CancellableRequest = submit(onComplete) {
|
||||||
|
parseDubShareDetail(JSONObject(request("api/v1/dub-shares/$shareId", method = "GET")))
|
||||||
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
}
|
}
|
||||||
@@ -252,6 +274,34 @@ class UserApi internal constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseDubShareDetail(root: JSONObject): DubShareDetail {
|
||||||
|
val segmentsJson = root.optJSONArray("segments") ?: JSONArray()
|
||||||
|
val segments = List(segmentsJson.length()) { index ->
|
||||||
|
val item = segmentsJson.getJSONObject(index)
|
||||||
|
val detailsJson = item.optJSONObject("score_details") ?: JSONObject()
|
||||||
|
val scoreDetails = buildMap<String, Float> {
|
||||||
|
for (key in detailsJson.keys()) {
|
||||||
|
if (!detailsJson.isNull(key)) {
|
||||||
|
put(key, detailsJson.optDouble(key).toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DubShareSegmentDetail(
|
||||||
|
index = item.getInt("index"),
|
||||||
|
overallScore = item.optDouble("overall_score", 0.0).toFloat(),
|
||||||
|
recognizedText = item.optString("recognized_text"),
|
||||||
|
scoreDetails = scoreDetails,
|
||||||
|
audioUrl = item.getString("audio_url"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DubShareDetail(
|
||||||
|
shareId = root.getString("share_id"),
|
||||||
|
videoHash = root.getString("video_hash"),
|
||||||
|
title = root.optString("title"),
|
||||||
|
segments = segments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun request(
|
private fun request(
|
||||||
path: String,
|
path: String,
|
||||||
token: String? = null,
|
token: String? = null,
|
||||||
|
|||||||
@@ -9,5 +9,19 @@ internal class AssessmentState {
|
|||||||
scores[sentenceIndex] = result
|
scores[sentenceIndex] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun get(sentenceIndex: Int): ImitationAssessmentResult? = scores[sentenceIndex]
|
||||||
|
|
||||||
|
fun replaceAll(values: Map<Int, ImitationAssessmentResult>) {
|
||||||
|
scores.clear()
|
||||||
|
scores.putAll(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
scores.clear()
|
||||||
|
}
|
||||||
|
|
||||||
fun toMap(): Map<Int, ImitationAssessmentResult> = scores.toMap()
|
fun toMap(): Map<Int, ImitationAssessmentResult> = scores.toMap()
|
||||||
|
|
||||||
|
val size: Int
|
||||||
|
get() = scores.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package cn.learningpad.oraltrainer.sample
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.Locale
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Persists per-course dubbing audio and assessment scores under app filesDir. */
|
||||||
|
internal class DubbingHistoryStore(context: Context) {
|
||||||
|
private val rootDir = File(context.applicationContext.filesDir, "dubbing-history")
|
||||||
|
|
||||||
|
fun loadSnapshot(videoHash: String): Snapshot {
|
||||||
|
val normalized = normalize(videoHash) ?: return Snapshot()
|
||||||
|
val dir = videoDir(normalized)
|
||||||
|
if (!dir.isDirectory) {
|
||||||
|
return Snapshot()
|
||||||
|
}
|
||||||
|
val segments = dir.listFiles()
|
||||||
|
?.mapNotNull { file ->
|
||||||
|
val match = SEGMENT_PATTERN.matchEntire(file.name) ?: return@mapNotNull null
|
||||||
|
if (!file.isFile || file.length() <= 0L) return@mapNotNull null
|
||||||
|
match.groupValues[1].toInt() to file
|
||||||
|
}
|
||||||
|
?.toMap()
|
||||||
|
.orEmpty()
|
||||||
|
val merged = File(dir, MERGED_FILE_NAME).takeIf { it.isFile && it.length() > 0L }
|
||||||
|
val scores = readScores(File(dir, SCORES_FILE_NAME))
|
||||||
|
return Snapshot(segments = segments, scores = scores, mergedFile = merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSegment(videoHash: String, sentenceIndex: Int, source: File): File {
|
||||||
|
val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" }
|
||||||
|
val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a")
|
||||||
|
if (source.canonicalFile != dest.canonicalFile) {
|
||||||
|
source.copyTo(dest, overwrite = true)
|
||||||
|
}
|
||||||
|
// Any new recording invalidates the previous merged export.
|
||||||
|
File(videoDir(normalized), MERGED_FILE_NAME).delete()
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveScores(videoHash: String, scores: Map<Int, ImitationAssessmentResult>) {
|
||||||
|
val normalized = normalize(videoHash) ?: return
|
||||||
|
val file = File(videoDir(normalized), SCORES_FILE_NAME)
|
||||||
|
val root = JSONObject()
|
||||||
|
scores.toSortedMap().forEach { (index, result) ->
|
||||||
|
root.put(index.toString(), encodeScore(result))
|
||||||
|
}
|
||||||
|
file.writeText(root.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMerged(videoHash: String, source: File): File {
|
||||||
|
val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" }
|
||||||
|
val dest = File(videoDir(normalized), MERGED_FILE_NAME)
|
||||||
|
if (source.canonicalFile != dest.canonicalFile) {
|
||||||
|
source.copyTo(dest, overwrite = true)
|
||||||
|
}
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(videoHash: String) {
|
||||||
|
val normalized = normalize(videoHash) ?: return
|
||||||
|
videoDir(normalized).deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads one public share-audio URL into local history.
|
||||||
|
* Returns the saved file, or null when the download fails.
|
||||||
|
*/
|
||||||
|
fun downloadSegmentAudio(
|
||||||
|
baseUrl: String,
|
||||||
|
videoHash: String,
|
||||||
|
sentenceIndex: Int,
|
||||||
|
audioUrl: String,
|
||||||
|
): File? {
|
||||||
|
val normalized = normalize(videoHash) ?: return null
|
||||||
|
val absolute = when {
|
||||||
|
audioUrl.startsWith("http://") || audioUrl.startsWith("https://") -> audioUrl
|
||||||
|
audioUrl.startsWith("/") -> baseUrl.trimEnd('/') + audioUrl
|
||||||
|
else -> baseUrl.trimEnd('/') + "/" + audioUrl
|
||||||
|
}
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
return try {
|
||||||
|
connection = URL(absolute).openConnection() as HttpURLConnection
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 60_000
|
||||||
|
connection.requestMethod = "GET"
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a")
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
dest.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
if (dest.length() <= 0L) {
|
||||||
|
dest.delete()
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
dest
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun videoDir(videoHash: String): File =
|
||||||
|
File(rootDir, videoHash).also { it.mkdirs() }
|
||||||
|
|
||||||
|
private fun readScores(file: File): Map<Int, ImitationAssessmentResult> {
|
||||||
|
if (!file.isFile) {
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
val root = JSONObject(file.readText())
|
||||||
|
buildMap {
|
||||||
|
for (key in root.keys()) {
|
||||||
|
val index = key.toIntOrNull() ?: continue
|
||||||
|
val value = root.optJSONObject(key) ?: continue
|
||||||
|
put(index, decodeScore(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Snapshot(
|
||||||
|
val segments: Map<Int, File> = emptyMap(),
|
||||||
|
val scores: Map<Int, ImitationAssessmentResult> = emptyMap(),
|
||||||
|
val mergedFile: File? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SCORES_FILE_NAME = "scores.json"
|
||||||
|
private const val MERGED_FILE_NAME = "merged.m4a"
|
||||||
|
private val SEGMENT_PATTERN = Regex("""segment-(\d+)\.m4a""")
|
||||||
|
|
||||||
|
fun normalize(videoHash: String): String? {
|
||||||
|
val value = videoHash.trim().lowercase(Locale.US)
|
||||||
|
return value.takeIf { PlaybackFormat.isVideoHash(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeScore(result: ImitationAssessmentResult): JSONObject =
|
||||||
|
JSONObject().apply {
|
||||||
|
put("overall_score", result.overallScore.toDouble())
|
||||||
|
result.pronunciationScore?.let { put("pronunciation_score", it.toDouble()) }
|
||||||
|
result.fluencyScore?.let { put("fluency_score", it.toDouble()) }
|
||||||
|
result.completenessScore?.let { put("completeness_score", it.toDouble()) }
|
||||||
|
result.feedback?.let { put("feedback", it) }
|
||||||
|
if (result.details.isNotEmpty()) {
|
||||||
|
put(
|
||||||
|
"details",
|
||||||
|
JSONObject().also { details ->
|
||||||
|
result.details.forEach { (key, value) -> details.put(key, value) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.passed?.let { put("passed", it) }
|
||||||
|
result.contentScore?.let { put("content_score", it.toDouble()) }
|
||||||
|
result.durationScore?.let { put("duration_score", it.toDouble()) }
|
||||||
|
result.pauseScore?.let { put("pause_score", it.toDouble()) }
|
||||||
|
result.speechRateScore?.let { put("speech_rate_score", it.toDouble()) }
|
||||||
|
result.durationRatio?.let { put("duration_ratio", it.toDouble()) }
|
||||||
|
result.referenceText?.let { put("reference_text", it) }
|
||||||
|
result.recognizedText?.let { put("recognized_text", it) }
|
||||||
|
if (result.missingTokens.isNotEmpty()) {
|
||||||
|
put("missing_tokens", JSONArray(result.missingTokens))
|
||||||
|
}
|
||||||
|
if (result.extraTokens.isNotEmpty()) {
|
||||||
|
put("extra_tokens", JSONArray(result.extraTokens))
|
||||||
|
}
|
||||||
|
if (result.substitutions.isNotEmpty()) {
|
||||||
|
put(
|
||||||
|
"substitutions",
|
||||||
|
JSONArray().also { array ->
|
||||||
|
result.substitutions.forEach { (expected, actual) ->
|
||||||
|
array.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("expected", expected)
|
||||||
|
.put("actual", actual),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeScore(root: JSONObject): ImitationAssessmentResult {
|
||||||
|
val substitutions = buildList {
|
||||||
|
val array = root.optJSONArray("substitutions") ?: return@buildList
|
||||||
|
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.optDouble("overall_score", 0.0).toFloat(),
|
||||||
|
pronunciationScore = root.optNullableFloat("pronunciation_score"),
|
||||||
|
fluencyScore = root.optNullableFloat("fluency_score"),
|
||||||
|
completenessScore = root.optNullableFloat("completeness_score"),
|
||||||
|
feedback = root.optString("feedback").ifBlank { null },
|
||||||
|
details = details,
|
||||||
|
passed = if (root.has("passed")) root.optBoolean("passed") else null,
|
||||||
|
contentScore = root.optNullableFloat("content_score"),
|
||||||
|
durationScore = root.optNullableFloat("duration_score"),
|
||||||
|
pauseScore = root.optNullableFloat("pause_score"),
|
||||||
|
speechRateScore = root.optNullableFloat("speech_rate_score"),
|
||||||
|
durationRatio = root.optNullableFloat("duration_ratio"),
|
||||||
|
referenceText = root.optString("reference_text").ifBlank { null },
|
||||||
|
recognizedText = root.optString("recognized_text").ifBlank { null },
|
||||||
|
missingTokens = root.optStringList("missing_tokens"),
|
||||||
|
extraTokens = root.optStringList("extra_tokens"),
|
||||||
|
substitutions = substitutions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.optNullableFloat(name: String): Float? {
|
||||||
|
if (!has(name) || isNull(name)) return null
|
||||||
|
return optDouble(name).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.optStringList(name: String): List<String> {
|
||||||
|
val array = optJSONArray(name) ?: return emptyList()
|
||||||
|
return buildList {
|
||||||
|
for (index in 0 until array.length()) {
|
||||||
|
val value = array.optString(index)
|
||||||
|
if (value.isNotBlank()) add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,8 @@ internal class DubbingPlayback(
|
|||||||
fun start(
|
fun start(
|
||||||
audioFile: File,
|
audioFile: File,
|
||||||
controller: OralTrainerController,
|
controller: OralTrainerController,
|
||||||
syncToSentence: Boolean,
|
sentenceStartMs: Long,
|
||||||
sentenceStartMs: Long?,
|
sentenceIndex: Int,
|
||||||
onComplete: () -> Unit,
|
onComplete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
listener?.let(player::removeListener)
|
listener?.let(player::removeListener)
|
||||||
@@ -45,11 +45,9 @@ internal class DubbingPlayback(
|
|||||||
}
|
}
|
||||||
}.also(player::addListener)
|
}.also(player::addListener)
|
||||||
|
|
||||||
if (syncToSentence && sentenceStartMs != null) {
|
// Mute original audio and drive the video for this sentence only.
|
||||||
controller.seekTo(sentenceStartMs)
|
|
||||||
}
|
|
||||||
controller.setVolume(0f)
|
controller.setVolume(0f)
|
||||||
controller.play()
|
controller.playSentenceAndStop(sentenceStartMs, sentenceIndex)
|
||||||
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
|
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.volume = 1f
|
player.volume = 1f
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import org.json.JSONObject
|
|||||||
internal class DubbingShareApi {
|
internal class DubbingShareApi {
|
||||||
fun upload(
|
fun upload(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
token: String,
|
||||||
videoHash: String,
|
videoHash: String,
|
||||||
videoTitle: String,
|
videoTitle: String,
|
||||||
segments: Map<Int, java.io.File>,
|
segments: Map<Int, java.io.File>,
|
||||||
scores: Map<Int, cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult>,
|
scores: Map<Int, cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult>,
|
||||||
): String {
|
): String {
|
||||||
|
require(token.isNotBlank()) { "Login required." }
|
||||||
val previousPolicy = StrictMode.getThreadPolicy()
|
val previousPolicy = StrictMode.getThreadPolicy()
|
||||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
||||||
val boundary = "oral-trainer-${System.currentTimeMillis()}"
|
val boundary = "oral-trainer-${System.currentTimeMillis()}"
|
||||||
@@ -25,6 +27,7 @@ internal class DubbingShareApi {
|
|||||||
connection.doOutput = true
|
connection.doOutput = true
|
||||||
connection.connectTimeout = 15_000
|
connection.connectTimeout = 15_000
|
||||||
connection.readTimeout = 120_000
|
connection.readTimeout = 120_000
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer $token")
|
||||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||||
|
|
||||||
DataOutputStream(connection.outputStream).use { output ->
|
DataOutputStream(connection.outputStream).use { output ->
|
||||||
@@ -83,7 +86,9 @@ internal class DubbingShareApi {
|
|||||||
val code = connection.responseCode
|
val code = connection.responseCode
|
||||||
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
|
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
|
||||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
require(code in 200..299) { "HTTP $code: $body" }
|
if (code !in 200..299) {
|
||||||
|
throw IllegalStateException(friendlyShareError(code, body))
|
||||||
|
}
|
||||||
val response = JSONObject(body).getString("share_id")
|
val response = JSONObject(body).getString("share_id")
|
||||||
return "$baseUrl/dub-shares/$response"
|
return "$baseUrl/dub-shares/$response"
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,4 +96,19 @@ internal class DubbingShareApi {
|
|||||||
StrictMode.setThreadPolicy(previousPolicy)
|
StrictMode.setThreadPolicy(previousPolicy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun friendlyShareError(code: Int, body: String): String {
|
||||||
|
val detail = runCatching { JSONObject(body).optString("detail") }.getOrNull()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: body
|
||||||
|
return when {
|
||||||
|
code == 401 ||
|
||||||
|
detail.contains("Login required", ignoreCase = true) ||
|
||||||
|
detail.contains("sign in", ignoreCase = true) ||
|
||||||
|
detail.contains("Session expired", ignoreCase = true) ->
|
||||||
|
"还未登录或登录已过期,请重新登录后再分享"
|
||||||
|
detail.isNotBlank() -> "HTTP $code: $detail"
|
||||||
|
else -> "HTTP $code"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,29 @@ internal class DubbingState {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
fun storeSegment(sentenceIndex: Int, file: File) {
|
fun storeSegment(sentenceIndex: Int, file: File) {
|
||||||
segments[sentenceIndex]?.delete()
|
val previous = segments.put(sentenceIndex, file)
|
||||||
segments[sentenceIndex] = file
|
if (previous != null && previous.absolutePath != file.absolutePath) {
|
||||||
|
previous.delete()
|
||||||
|
}
|
||||||
mergedFile = null
|
mergedFile = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMergedFile(file: File) {
|
fun setMergedFile(file: File?) {
|
||||||
mergedFile = file
|
mergedFile = file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceAll(newSegments: Map<Int, File>, merged: File?) {
|
||||||
|
val keep = newSegments.values.map { it.absolutePath }.toHashSet()
|
||||||
|
segments.values
|
||||||
|
.filter { it.absolutePath !in keep }
|
||||||
|
.forEach { /* keep history files on disk; only drop memory refs */ }
|
||||||
|
segments.clear()
|
||||||
|
segments.putAll(newSegments)
|
||||||
|
mergedFile = merged?.takeIf { it.exists() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMemory() {
|
||||||
|
segments.clear()
|
||||||
|
mergedFile = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class MainActivity : Activity() {
|
|||||||
private val dubbingState = DubbingState()
|
private val dubbingState = DubbingState()
|
||||||
private val assessmentState = AssessmentState()
|
private val assessmentState = AssessmentState()
|
||||||
private val dubbingShareApi = DubbingShareApi()
|
private val dubbingShareApi = DubbingShareApi()
|
||||||
|
private lateinit var dubbingHistoryStore: DubbingHistoryStore
|
||||||
|
|
||||||
private lateinit var attemptRecorder: AttemptRecorder
|
private lateinit var attemptRecorder: AttemptRecorder
|
||||||
private lateinit var dubbingMerger: DubbingMerger
|
private lateinit var dubbingMerger: DubbingMerger
|
||||||
@@ -57,6 +58,7 @@ class MainActivity : Activity() {
|
|||||||
attemptRecorder = AttemptRecorder(this)
|
attemptRecorder = AttemptRecorder(this)
|
||||||
dubbingMerger = DubbingMerger(cacheDir)
|
dubbingMerger = DubbingMerger(cacheDir)
|
||||||
dubbingPlaybackController = DubbingPlayback(this)
|
dubbingPlaybackController = DubbingPlayback(this)
|
||||||
|
dubbingHistoryStore = DubbingHistoryStore(this)
|
||||||
configureWindow()
|
configureWindow()
|
||||||
|
|
||||||
authSessionStore = AuthSessionStore(this)
|
authSessionStore = AuthSessionStore(this)
|
||||||
@@ -89,6 +91,9 @@ class MainActivity : Activity() {
|
|||||||
dubbingShareApi = dubbingShareApi,
|
dubbingShareApi = dubbingShareApi,
|
||||||
dubbingPlayback = dubbingPlaybackController,
|
dubbingPlayback = dubbingPlaybackController,
|
||||||
assessmentState = assessmentState,
|
assessmentState = assessmentState,
|
||||||
|
historyStore = dubbingHistoryStore,
|
||||||
|
userApi = sdk.userApi,
|
||||||
|
tokenProvider = { authSessionStore.session?.token.orEmpty() },
|
||||||
host = testHost(),
|
host = testHost(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,6 +114,7 @@ class MainActivity : Activity() {
|
|||||||
screenFactory.renderCatalog()
|
screenFactory.renderCatalog()
|
||||||
refreshCurrentUi()
|
refreshCurrentUi()
|
||||||
if (screenState.activeModule == Module.TEST) {
|
if (screenState.activeModule == Module.TEST) {
|
||||||
|
testSession.ensureHistoryForCurrentCourse()
|
||||||
testSession.refreshTestUi()
|
testSession.refreshTestUi()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,6 +274,9 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun switchModule(module: Module) {
|
private fun switchModule(module: Module) {
|
||||||
|
if (module != Module.TEST) {
|
||||||
|
testSession.stopDubbingIfNeeded()
|
||||||
|
}
|
||||||
screenState.activeModule = module
|
screenState.activeModule = module
|
||||||
if (module == Module.COURSE || module == Module.MINE) {
|
if (module == Module.COURSE || module == Module.MINE) {
|
||||||
catalogController.loadUserData(module) {
|
catalogController.loadUserData(module) {
|
||||||
@@ -310,9 +319,14 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaChanged(item: TrainingMediaItem?) {
|
override fun onMediaChanged(item: TrainingMediaItem?) {
|
||||||
if (!bindings.hasPlayerChrome) return
|
if (bindings.hasPlayerChrome) {
|
||||||
bindings.lessonTitleText.text = item?.title ?: "未选择课程"
|
bindings.lessonTitleText.text = item?.title ?: "未选择课程"
|
||||||
screenState.currentSentenceCount = item?.sentences?.size ?: 0
|
screenState.currentSentenceCount = item?.sentences?.size ?: 0
|
||||||
|
}
|
||||||
|
testSession.ensureHistoryForCurrentCourse()
|
||||||
|
if (screenState.activeModule == Module.TEST) {
|
||||||
|
testSession.refreshTestUi()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSentenceChanged(sentence: SentenceBoundary?) {
|
override fun onSentenceChanged(sentence: SentenceBoundary?) {
|
||||||
@@ -408,15 +422,21 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrevSentence() {
|
override fun onPrevSentence() {
|
||||||
|
testSession.stopDubbingIfNeeded()
|
||||||
controller.seekToPreviousSentence()
|
controller.seekToPreviousSentence()
|
||||||
|
testSession.refreshTestUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReplaySentence() {
|
override fun onReplaySentence() {
|
||||||
|
testSession.stopDubbingIfNeeded()
|
||||||
|
testSession.refreshTestUi()
|
||||||
controller.playCurrentSentenceAndStop()
|
controller.playCurrentSentenceAndStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNextSentence() {
|
override fun onNextSentence() {
|
||||||
|
testSession.stopDubbingIfNeeded()
|
||||||
controller.seekToNextSentence()
|
controller.seekToNextSentence()
|
||||||
|
testSession.refreshTestUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onToggleRecording() = testSession.toggleRecording()
|
override fun onToggleRecording() = testSession.toggleRecording()
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ internal object SampleTheme {
|
|||||||
val textDark = Color.rgb(18, 24, 31)
|
val textDark = Color.rgb(18, 24, 31)
|
||||||
val textMuted = Color.rgb(151, 162, 174)
|
val textMuted = Color.rgb(151, 162, 174)
|
||||||
val textSubtle = Color.rgb(88, 98, 108)
|
val textSubtle = Color.rgb(88, 98, 108)
|
||||||
|
val danger = Color.rgb(220, 38, 38)
|
||||||
val textOverlay = Color.rgb(163, 172, 182)
|
val textOverlay = Color.rgb(163, 172, 182)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import android.app.Activity
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||||
|
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
/** Recording, assessment, and dubbing flow for the TEST module. */
|
/** Recording, assessment, and dubbing flow for the TEST module. */
|
||||||
internal class TestSessionController(
|
internal class TestSessionController(
|
||||||
@@ -24,6 +29,9 @@ internal class TestSessionController(
|
|||||||
private val dubbingShareApi: DubbingShareApi,
|
private val dubbingShareApi: DubbingShareApi,
|
||||||
private val dubbingPlayback: DubbingPlayback,
|
private val dubbingPlayback: DubbingPlayback,
|
||||||
private val assessmentState: AssessmentState,
|
private val assessmentState: AssessmentState,
|
||||||
|
private val historyStore: DubbingHistoryStore,
|
||||||
|
private val userApi: UserApi,
|
||||||
|
private val tokenProvider: () -> String,
|
||||||
private val host: Host,
|
private val host: Host,
|
||||||
) {
|
) {
|
||||||
interface Host {
|
interface Host {
|
||||||
@@ -33,6 +41,15 @@ internal class TestSessionController(
|
|||||||
fun refreshPlayerUi()
|
fun refreshPlayerUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val background = Executors.newSingleThreadExecutor()
|
||||||
|
private val remoteLoadGeneration = AtomicInteger(0)
|
||||||
|
|
||||||
|
private var loadedVideoHash: String? = null
|
||||||
|
private var assessing = false
|
||||||
|
private var sharing = false
|
||||||
|
private var historyStatusMessage: String? = null
|
||||||
|
|
||||||
val isDubbingActive: Boolean
|
val isDubbingActive: Boolean
|
||||||
get() = dubbingPlayback.isActive
|
get() = dubbingPlayback.isActive
|
||||||
|
|
||||||
@@ -47,8 +64,10 @@ internal class TestSessionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
remoteLoadGeneration.incrementAndGet()
|
||||||
attemptRecorder.discard()
|
attemptRecorder.discard()
|
||||||
dubbingPlayback.release()
|
dubbingPlayback.release()
|
||||||
|
background.shutdownNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopDubbingIfNeeded() {
|
fun stopDubbingIfNeeded() {
|
||||||
@@ -57,7 +76,29 @@ internal class TestSessionController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loads local (and if needed remote) history for the active cloud course. */
|
||||||
|
fun ensureHistoryForCurrentCourse(forceRemote: Boolean = false) {
|
||||||
|
val videoHash = currentVideoHash()
|
||||||
|
if (videoHash == null) {
|
||||||
|
if (loadedVideoHash != null) {
|
||||||
|
loadedVideoHash = null
|
||||||
|
dubbingState.clearMemory()
|
||||||
|
assessmentState.clear()
|
||||||
|
historyStatusMessage = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (loadedVideoHash == videoHash && !forceRemote) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadLocalHistory(videoHash)
|
||||||
|
if (forceRemote || dubbingState.segments.isEmpty()) {
|
||||||
|
restoreRemoteHistory(videoHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshTestUi() {
|
fun refreshTestUi() {
|
||||||
|
ensureHistoryForCurrentCourse()
|
||||||
val ui = host.bindings
|
val ui = host.bindings
|
||||||
if (!ui.hasTestChrome) {
|
if (!ui.hasTestChrome) {
|
||||||
return
|
return
|
||||||
@@ -68,22 +109,40 @@ internal class TestSessionController(
|
|||||||
val sentence = controller.currentSentence()
|
val sentence = controller.currentSentence()
|
||||||
val currentRecording = sentence?.let { dubbingState.segments[it.index] }
|
val currentRecording = sentence?.let { dubbingState.segments[it.index] }
|
||||||
val segmentLabel = when {
|
val segmentLabel = when {
|
||||||
currentRecording != null -> " · 本句已录音,重录将覆盖"
|
currentRecording != null -> "本句已有配音,重录将覆盖"
|
||||||
else -> ""
|
else -> null
|
||||||
}
|
}
|
||||||
ui.testStatusText.text = if (item == null || sentence == null) {
|
|
||||||
"请先在训练模块选择课程$segmentLabel"
|
if (!assessing && !attemptRecorder.isRecording) {
|
||||||
} else {
|
val score = sentence?.let { assessmentState.get(it.index) }
|
||||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
|
if (score != null) {
|
||||||
}
|
applyAssessmentResult(score, segmentLabel)
|
||||||
refreshDubButtons()
|
} else {
|
||||||
if (dubbingState.mergedFile == null) {
|
ui.testStatusText.text = when {
|
||||||
ui.dubbingStatusText?.text = when (dubbingState.segments.size) {
|
item == null || sentence == null -> "请先在训练模块选择课程"
|
||||||
0 -> "配音:请先录音"
|
segmentLabel != null -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句) · $segmentLabel"
|
||||||
1 -> "配音:已保存 1 句,可生成配音"
|
else -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
|
||||||
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
}
|
||||||
|
ui.scoreSummaryText.text = "尚未评测"
|
||||||
|
ui.scoreDetailText.text = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshDubButtons()
|
||||||
|
if (!sharing) {
|
||||||
|
val historyNote = historyStatusMessage
|
||||||
|
if (dubbingState.mergedFile?.exists() == true) {
|
||||||
|
ui.dubbingStatusText?.text = historyNote
|
||||||
|
?: "已合成 ${dubbingState.segments.size} 句配音,可点击“分享配音”"
|
||||||
|
} else {
|
||||||
|
ui.dubbingStatusText?.text = historyNote ?: when (dubbingState.segments.size) {
|
||||||
|
0 -> "配音:请先录音(进入本页会自动加载历史配音)"
|
||||||
|
1 -> "配音:已保存 1 句,可生成配音"
|
||||||
|
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.dubbingStatusText?.setTextColor(SampleTheme.accentDeep)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleRecording() {
|
fun toggleRecording() {
|
||||||
@@ -95,6 +154,7 @@ internal class TestSessionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startRecording() {
|
fun startRecording() {
|
||||||
|
stopDubbingIfNeeded()
|
||||||
controller.pause()
|
controller.pause()
|
||||||
val ui = host.bindings
|
val ui = host.bindings
|
||||||
if (!ui.hasTestChrome) {
|
if (!ui.hasTestChrome) {
|
||||||
@@ -136,18 +196,41 @@ internal class TestSessionController(
|
|||||||
host.setMainStatus("已停止配音播放")
|
host.setMainStatus("已停止配音播放")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val audio = dubbingState.mergedFile ?: activeAudioForPlayback() ?: return
|
val sentence = controller.currentSentence()
|
||||||
startDubPlayback(audio)
|
if (sentence == null) {
|
||||||
|
host.setMainStatus("请先选择要播放的句子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val audio = dubbingState.segments[sentence.index]
|
||||||
|
if (audio == null || !audio.exists()) {
|
||||||
|
host.setMainStatus("当前句还没有录音,请先录音后再播放")
|
||||||
|
refreshTestUi()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startDubPlayback(audio, sentence.startMs, sentence.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mergeDubSegments() {
|
fun mergeDubSegments() {
|
||||||
|
stopDubbingIfNeeded()
|
||||||
|
val videoHash = currentVideoHash()
|
||||||
val segments = dubbingState.segments.toSortedMap().map { (index, file) -> index to file }
|
val segments = dubbingState.segments.toSortedMap().map { (index, file) -> index to file }
|
||||||
if (segments.isEmpty()) {
|
if (segments.isEmpty()) {
|
||||||
|
setDubbingStatus("没有可合成的配音")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val output = dubbingMerger.merge(segments)
|
val output = dubbingMerger.merge(segments)
|
||||||
dubbingState.setMergedFile(output)
|
val persisted = if (videoHash != null) {
|
||||||
|
historyStore.saveMerged(videoHash, output).also {
|
||||||
|
if (it.absolutePath != output.absolutePath) {
|
||||||
|
output.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
}
|
||||||
|
dubbingState.setMergedFile(persisted)
|
||||||
|
historyStatusMessage = null
|
||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”")
|
setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”")
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
@@ -160,30 +243,62 @@ internal class TestSessionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun shareMergedDubbing() {
|
fun shareMergedDubbing() {
|
||||||
|
stopDubbingIfNeeded()
|
||||||
|
if (sharing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val item = controller.currentTrainingItem()
|
val item = controller.currentTrainingItem()
|
||||||
if (item == null || !PlaybackFormat.isVideoHash(item.id)) {
|
if (item == null || !PlaybackFormat.isVideoHash(item.id)) {
|
||||||
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
|
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
val token = tokenProvider().trim()
|
||||||
val shareUrl = dubbingShareApi.upload(
|
if (token.isEmpty()) {
|
||||||
baseUrl = SERVER_BASE_URL,
|
setDubbingStatus("分享失败:请先登录后再分享配音")
|
||||||
videoHash = item.id,
|
return
|
||||||
videoTitle = item.title,
|
}
|
||||||
segments = dubbingState.segments,
|
val segmentsSnapshot = dubbingState.segments.toMap()
|
||||||
scores = assessmentState.toMap(),
|
val scoresSnapshot = assessmentState.toMap()
|
||||||
)
|
val videoHash = item.id
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val videoTitle = item.title
|
||||||
type = "text/plain"
|
sharing = true
|
||||||
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
|
setDubbingStatus("正在生成分享链接,请稍候...", alert = true)
|
||||||
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
refreshDubButtons()
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
background.execute {
|
||||||
|
try {
|
||||||
|
val shareUrl = dubbingShareApi.upload(
|
||||||
|
baseUrl = SERVER_BASE_URL,
|
||||||
|
token = token,
|
||||||
|
videoHash = videoHash,
|
||||||
|
videoTitle = videoTitle,
|
||||||
|
segments = segmentsSnapshot,
|
||||||
|
scores = scoresSnapshot,
|
||||||
|
)
|
||||||
|
mainHandler.post {
|
||||||
|
sharing = false
|
||||||
|
if (!host.bindings.hasTestChrome) {
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
|
||||||
|
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
activity.startActivity(Intent.createChooser(intent, "分享配音"))
|
||||||
|
setDubbingStatus("已生成分享链接:$shareUrl")
|
||||||
|
refreshDubButtons()
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.e(TAG, "share failed", error)
|
||||||
|
val detail = error.message?.takeIf { it.isNotBlank() }
|
||||||
|
?: error.javaClass.simpleName
|
||||||
|
mainHandler.post {
|
||||||
|
sharing = false
|
||||||
|
setDubbingStatus("分享失败:$detail", alert = true)
|
||||||
|
refreshDubButtons()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
activity.startActivity(Intent.createChooser(intent, "分享配音"))
|
|
||||||
setDubbingStatus("已生成分享链接:$shareUrl")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "share failed", error)
|
|
||||||
setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +310,160 @@ internal class TestSessionController(
|
|||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadLocalHistory(videoHash: String) {
|
||||||
|
val snapshot = historyStore.loadSnapshot(videoHash)
|
||||||
|
dubbingState.replaceAll(snapshot.segments, snapshot.mergedFile)
|
||||||
|
assessmentState.replaceAll(snapshot.scores)
|
||||||
|
loadedVideoHash = videoHash
|
||||||
|
historyStatusMessage = when {
|
||||||
|
snapshot.segments.isNotEmpty() && snapshot.scores.isNotEmpty() ->
|
||||||
|
"已加载历史配音 ${snapshot.segments.size} 句、评分 ${snapshot.scores.size} 句"
|
||||||
|
snapshot.segments.isNotEmpty() ->
|
||||||
|
"已加载历史配音 ${snapshot.segments.size} 句"
|
||||||
|
snapshot.scores.isNotEmpty() ->
|
||||||
|
"已加载历史评分 ${snapshot.scores.size} 句(暂无本地录音)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"loaded local history video=$videoHash segments=${snapshot.segments.size} scores=${snapshot.scores.size}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreRemoteHistory(videoHash: String) {
|
||||||
|
val token = tokenProvider().trim()
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val generation = remoteLoadGeneration.incrementAndGet()
|
||||||
|
historyStatusMessage = when {
|
||||||
|
dubbingState.segments.isEmpty() && assessmentState.size == 0 -> "正在同步历史配音…"
|
||||||
|
else -> historyStatusMessage
|
||||||
|
}
|
||||||
|
if (host.bindings.hasTestChrome) {
|
||||||
|
host.bindings.dubbingStatusText?.text = historyStatusMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest score summary per sentence.
|
||||||
|
userApi.results(token) { result ->
|
||||||
|
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||||
|
return@results
|
||||||
|
}
|
||||||
|
result.onSuccess { rows ->
|
||||||
|
val latestBySentence = linkedMapOf<Int, cn.learningpad.oraltrainer.sdk.UserResult>()
|
||||||
|
rows.filter { it.videoHash.equals(videoHash, ignoreCase = true) }
|
||||||
|
.sortedBy { it.createdAt }
|
||||||
|
.forEach { latestBySentence[it.sentenceIndex] = it }
|
||||||
|
var added = 0
|
||||||
|
latestBySentence.forEach { (index, row) ->
|
||||||
|
if (assessmentState.get(index) == null) {
|
||||||
|
assessmentState.store(
|
||||||
|
index,
|
||||||
|
ImitationAssessmentResult(
|
||||||
|
overallScore = row.overallScore,
|
||||||
|
passed = row.passed,
|
||||||
|
referenceText = row.sentenceText.takeIf { it.isNotBlank() },
|
||||||
|
feedback = "来自历史测试记录",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added > 0) {
|
||||||
|
historyStore.saveScores(videoHash, assessmentState.toMap())
|
||||||
|
historyStatusMessage = "已同步历史评分 ${assessmentState.size} 句"
|
||||||
|
refreshTestUi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest shared dubbing audio for this course.
|
||||||
|
userApi.dubShares(token) { shareResult ->
|
||||||
|
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||||
|
return@dubShares
|
||||||
|
}
|
||||||
|
val latestShare = shareResult.getOrNull()
|
||||||
|
?.filter { it.videoHash.equals(videoHash, ignoreCase = true) }
|
||||||
|
?.maxByOrNull { it.createdAt }
|
||||||
|
if (latestShare == null) {
|
||||||
|
if (dubbingState.segments.isEmpty() && assessmentState.size == 0) {
|
||||||
|
historyStatusMessage = "暂无历史配音,请开始录音"
|
||||||
|
if (host.bindings.hasTestChrome) {
|
||||||
|
host.bindings.dubbingStatusText?.text = historyStatusMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@dubShares
|
||||||
|
}
|
||||||
|
if (dubbingState.segments.isNotEmpty()) {
|
||||||
|
return@dubShares
|
||||||
|
}
|
||||||
|
userApi.dubShareDetail(latestShare.shareId) { detailResult ->
|
||||||
|
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||||
|
return@dubShareDetail
|
||||||
|
}
|
||||||
|
val detail = detailResult.getOrNull() ?: run {
|
||||||
|
Log.w(TAG, "dub share detail failed: ${detailResult.exceptionOrNull()?.message}")
|
||||||
|
return@dubShareDetail
|
||||||
|
}
|
||||||
|
background.execute {
|
||||||
|
val downloaded = linkedMapOf<Int, File>()
|
||||||
|
val remoteScores = linkedMapOf<Int, ImitationAssessmentResult>()
|
||||||
|
detail.segments.forEach { segment ->
|
||||||
|
val file = historyStore.downloadSegmentAudio(
|
||||||
|
baseUrl = SERVER_BASE_URL,
|
||||||
|
videoHash = videoHash,
|
||||||
|
sentenceIndex = segment.index,
|
||||||
|
audioUrl = segment.audioUrl,
|
||||||
|
)
|
||||||
|
if (file != null) {
|
||||||
|
downloaded[segment.index] = file
|
||||||
|
}
|
||||||
|
if (assessmentState.get(segment.index) == null) {
|
||||||
|
remoteScores[segment.index] = ImitationAssessmentResult(
|
||||||
|
overallScore = segment.overallScore,
|
||||||
|
contentScore = segment.scoreDetails["content_score"],
|
||||||
|
fluencyScore = segment.scoreDetails["fluency_score"],
|
||||||
|
durationScore = segment.scoreDetails["duration_score"],
|
||||||
|
pauseScore = segment.scoreDetails["pause_score"],
|
||||||
|
speechRateScore = segment.scoreDetails["speech_rate_score"],
|
||||||
|
recognizedText = segment.recognizedText.takeIf { it.isNotBlank() },
|
||||||
|
feedback = "来自历史配音分享",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainHandler.post {
|
||||||
|
if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) {
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
if (downloaded.isNotEmpty()) {
|
||||||
|
val mergedSegments = dubbingState.segments.toMutableMap()
|
||||||
|
mergedSegments.putAll(downloaded)
|
||||||
|
dubbingState.replaceAll(mergedSegments, dubbingState.mergedFile)
|
||||||
|
}
|
||||||
|
if (remoteScores.isNotEmpty()) {
|
||||||
|
val mergedScores = assessmentState.toMap().toMutableMap()
|
||||||
|
remoteScores.forEach { (index, score) ->
|
||||||
|
if (mergedScores[index] == null) {
|
||||||
|
mergedScores[index] = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assessmentState.replaceAll(mergedScores)
|
||||||
|
historyStore.saveScores(videoHash, assessmentState.toMap())
|
||||||
|
}
|
||||||
|
historyStatusMessage = when {
|
||||||
|
downloaded.isNotEmpty() ->
|
||||||
|
"已从云端恢复配音 ${downloaded.size} 句"
|
||||||
|
assessmentState.size > 0 ->
|
||||||
|
"已同步历史评分 ${assessmentState.size} 句"
|
||||||
|
else -> "暂无可用的历史配音音频"
|
||||||
|
}
|
||||||
|
refreshTestUi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun stopRecordingAndAssess() {
|
private fun stopRecordingAndAssess() {
|
||||||
if (!attemptRecorder.isRecording) return
|
if (!attemptRecorder.isRecording) return
|
||||||
val ui = host.bindings
|
val ui = host.bindings
|
||||||
@@ -215,12 +484,28 @@ internal class TestSessionController(
|
|||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dubbingState.storeSegment(sentenceIndex, file)
|
val videoHash = currentVideoHash()
|
||||||
submitAssessment(Uri.fromFile(file))
|
val persisted = if (videoHash != null) {
|
||||||
|
try {
|
||||||
|
historyStore.saveSegment(videoHash, sentenceIndex, file).also {
|
||||||
|
if (it.absolutePath != file.absolutePath) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.e(TAG, "persist segment failed", error)
|
||||||
|
file
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
file
|
||||||
|
}
|
||||||
|
dubbingState.storeSegment(sentenceIndex, persisted)
|
||||||
|
historyStatusMessage = null
|
||||||
|
submitAssessment(Uri.fromFile(persisted), sentenceIndex)
|
||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun submitAssessment(recordingUri: Uri) {
|
private fun submitAssessment(recordingUri: Uri, sentenceIndex: Int) {
|
||||||
val ui = host.bindings
|
val ui = host.bindings
|
||||||
val item = controller.currentTrainingItem()
|
val item = controller.currentTrainingItem()
|
||||||
val sentence = controller.currentSentence()
|
val sentence = controller.currentSentence()
|
||||||
@@ -236,6 +521,7 @@ internal class TestSessionController(
|
|||||||
ui.testStatusText.text = "未配置评分密钥:请在 AppConfig 填入服务器 .env 的 CLIENT_API_KEY"
|
ui.testStatusText.text = "未配置评分密钥:请在 AppConfig 填入服务器 .env 的 CLIENT_API_KEY"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assessing = true
|
||||||
ui.testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
|
ui.testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
|
||||||
ui.scoreSummaryText.text = "评测中…"
|
ui.scoreSummaryText.text = "评测中…"
|
||||||
ui.scoreDetailText.text = ""
|
ui.scoreDetailText.text = ""
|
||||||
@@ -247,13 +533,24 @@ internal class TestSessionController(
|
|||||||
metadata = mapOf("video_hash" to item.id),
|
metadata = mapOf("video_hash" to item.id),
|
||||||
callback = object : ImitationAssessmentCallback {
|
callback = object : ImitationAssessmentCallback {
|
||||||
override fun onResult(result: ImitationAssessmentResult) {
|
override fun onResult(result: ImitationAssessmentResult) {
|
||||||
|
assessing = false
|
||||||
|
assessmentState.store(sentenceIndex, result)
|
||||||
|
currentVideoHash()?.let { hash ->
|
||||||
|
historyStore.saveScores(hash, assessmentState.toMap())
|
||||||
|
}
|
||||||
if (host.bindings.hasTestChrome) {
|
if (host.bindings.hasTestChrome) {
|
||||||
applyAssessmentResult(result)
|
applyAssessmentResult(result)
|
||||||
|
refreshDubButtons()
|
||||||
|
host.bindings.dubbingStatusText?.text = when (dubbingState.segments.size) {
|
||||||
|
0 -> "配音:请先录音"
|
||||||
|
1 -> "配音:已保存 1 句,可生成配音"
|
||||||
|
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assessmentState.store(sentence.index, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(error: Throwable) {
|
override fun onError(error: Throwable) {
|
||||||
|
assessing = false
|
||||||
if (host.bindings.hasTestChrome) {
|
if (host.bindings.hasTestChrome) {
|
||||||
host.bindings.testStatusText.text = "评测失败:${error.message.orEmpty()}"
|
host.bindings.testStatusText.text = "评测失败:${error.message.orEmpty()}"
|
||||||
host.bindings.scoreSummaryText.text = "评测失败"
|
host.bindings.scoreSummaryText.text = "评测失败"
|
||||||
@@ -263,9 +560,17 @@ internal class TestSessionController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyAssessmentResult(result: ImitationAssessmentResult) {
|
private fun applyAssessmentResult(
|
||||||
|
result: ImitationAssessmentResult,
|
||||||
|
segmentLabel: String? = null,
|
||||||
|
) {
|
||||||
val ui = host.bindings
|
val ui = host.bindings
|
||||||
ui.testStatusText.text = if (result.passed == true) "已通过" else "未通过"
|
val passLabel = when (result.passed) {
|
||||||
|
true -> "已通过"
|
||||||
|
false -> "未通过"
|
||||||
|
null -> "已评测"
|
||||||
|
}
|
||||||
|
ui.testStatusText.text = listOfNotNull(passLabel, segmentLabel).joinToString(" · ")
|
||||||
ui.scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
|
ui.scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
|
||||||
ui.scoreDetailText.text = buildString {
|
ui.scoreDetailText.text = buildString {
|
||||||
result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) }
|
result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) }
|
||||||
@@ -302,49 +607,51 @@ internal class TestSessionController(
|
|||||||
|
|
||||||
private fun refreshDubButtons() {
|
private fun refreshDubButtons() {
|
||||||
val ui = host.bindings
|
val ui = host.bindings
|
||||||
|
if (!ui.hasTestChrome) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val dubbingActive = dubbingPlayback.isActive
|
val dubbingActive = dubbingPlayback.isActive
|
||||||
ui.playDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
val currentSentenceAudio = controller.currentSentence()?.let { dubbingState.segments[it.index] }
|
||||||
|
// Keep the play button tappable while active so the user can stop playback.
|
||||||
|
ui.playDubButton.isEnabled = dubbingActive || currentSentenceAudio != null
|
||||||
ui.mergeDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
ui.mergeDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
||||||
ui.shareDubButton.isEnabled = dubbingState.mergedFile?.exists() == true && !dubbingActive
|
ui.shareDubButton.isEnabled =
|
||||||
val activeAudio = controller.currentSentence()?.let { dubbingState.segments[it.index] }
|
dubbingState.mergedFile?.exists() == true && !dubbingActive && !sharing
|
||||||
ui.playDubButton.text = when {
|
ui.playDubButton.text = when {
|
||||||
dubbingActive -> "停止配音"
|
dubbingActive -> "停止播放"
|
||||||
dubbingState.mergedFile?.exists() == true -> "播放配音"
|
currentSentenceAudio != null -> "播放本句录音"
|
||||||
activeAudio != null -> "播放本句配音"
|
dubbingState.segments.isNotEmpty() -> "播放本句录音"
|
||||||
else -> "播放录音(${dubbingState.segments.size})"
|
else -> "播放录音"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeAudioForPlayback(): File? {
|
private fun startDubPlayback(audioFile: File, sentenceStartMs: Long, sentenceIndex: Int) {
|
||||||
if (dubbingState.mergedFile != null) {
|
|
||||||
return dubbingState.mergedFile
|
|
||||||
}
|
|
||||||
return controller.currentSentence()?.let { sentence -> dubbingState.segments[sentence.index] }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startDubPlayback(audioFile: File) {
|
|
||||||
val sentence = controller.currentSentence()
|
|
||||||
dubbingPlayback.start(
|
dubbingPlayback.start(
|
||||||
audioFile = audioFile,
|
audioFile = audioFile,
|
||||||
controller = controller,
|
controller = controller,
|
||||||
syncToSentence = dubbingState.mergedFile == null,
|
sentenceStartMs = sentenceStartMs,
|
||||||
sentenceStartMs = sentence?.startMs,
|
sentenceIndex = sentenceIndex,
|
||||||
) {
|
) {
|
||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
host.setMainStatus("配音播放完成")
|
host.setMainStatus("本句录音播放完成")
|
||||||
}
|
}
|
||||||
refreshTestUi()
|
refreshTestUi()
|
||||||
host.setMainStatus(
|
host.setMainStatus("正在播放当前句录音,原视频画面同步但原声静音")
|
||||||
if (dubbingState.mergedFile == null) {
|
|
||||||
"正在播放当前句配音,原视频画面同步但原声静音"
|
|
||||||
} else {
|
|
||||||
"正在播放整段配音,原视频画面同步但原声静音"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setDubbingStatus(message: String) {
|
private fun setDubbingStatus(message: String, alert: Boolean = false) {
|
||||||
host.bindings.dubbingStatusText?.text = message
|
historyStatusMessage = null
|
||||||
|
host.bindings.dubbingStatusText?.apply {
|
||||||
|
text = message
|
||||||
|
setTextColor(if (alert) SampleTheme.danger else SampleTheme.accentDeep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentVideoHash(): String? {
|
||||||
|
val id = controller.currentTrainingItem()?.id
|
||||||
|
?: screenState.currentCourse?.videoHash
|
||||||
|
?: screenState.activeItemId
|
||||||
|
return DubbingHistoryStore.normalize(id.orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@@ -171,15 +171,19 @@ enum ImitationAssessor {
|
|||||||
}
|
}
|
||||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
body.append(Data("--\(boundary)--\r\n".utf8))
|
||||||
|
|
||||||
|
var headers = [
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||||
|
]
|
||||||
|
if !AppConfig.authToken.isEmpty {
|
||||||
|
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||||
|
}
|
||||||
let result = try await HTTPClient.execute(
|
let result = try await HTTPClient.execute(
|
||||||
baseURL: AppConfig.serverBaseURL,
|
baseURL: AppConfig.serverBaseURL,
|
||||||
path: "api/v1/dub-shares",
|
path: "api/v1/dub-shares",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeout: 120,
|
timeout: 120,
|
||||||
headers: [
|
headers: headers,
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
|
||||||
],
|
|
||||||
body: body
|
body: body
|
||||||
)
|
)
|
||||||
guard (200...299).contains(result.statusCode) else {
|
guard (200...299).contains(result.statusCode) else {
|
||||||
|
|||||||
@@ -58,13 +58,16 @@ struct TestView: View {
|
|||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
sentenceButton("上一句") {
|
sentenceButton("上一句") {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
controller.seekToPreviousSentence()
|
controller.seekToPreviousSentence()
|
||||||
}
|
}
|
||||||
sentenceButton("播放原句") {
|
sentenceButton("播放原句") {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
controller.playCurrentSentenceAndStop()
|
controller.playCurrentSentenceAndStop()
|
||||||
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
|
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
|
||||||
}
|
}
|
||||||
sentenceButton("下一句") {
|
sentenceButton("下一句") {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
controller.seekToNextSentence()
|
controller.seekToNextSentence()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,19 +83,24 @@ struct TestView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(isAssessing)
|
.disabled(isAssessing)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
dubButton(
|
||||||
|
dubbingPlayback ? "停止播放" : "播放本句录音",
|
||||||
|
enabled: dubbingPlayback || (controller.currentSentence.flatMap { dubSegments[$0.index] } != nil)
|
||||||
|
) {
|
||||||
toggleDubPlayback()
|
toggleDubPlayback()
|
||||||
}
|
}
|
||||||
dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
generateDubShareAudio()
|
generateDubShareAudio()
|
||||||
}
|
}
|
||||||
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
|
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
shareMergedDub()
|
shareMergedDub()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(dubbingStatus)
|
Text(dubbingStatus)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Theme.accentDeep)
|
.foregroundColor(isSharingDub ? Theme.danger : Theme.accentDeep)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text(scoreSummary)
|
Text(scoreSummary)
|
||||||
.font(.system(size: 22, weight: .bold))
|
.font(.system(size: 22, weight: .bold))
|
||||||
@@ -159,6 +167,7 @@ struct TestView: View {
|
|||||||
// MARK: - 录音
|
// MARK: - 录音
|
||||||
|
|
||||||
private func toggleRecording() {
|
private func toggleRecording() {
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
if isRecording {
|
if isRecording {
|
||||||
stopRecordingAndAssess()
|
stopRecordingAndAssess()
|
||||||
} else {
|
} else {
|
||||||
@@ -394,32 +403,40 @@ struct TestView: View {
|
|||||||
|
|
||||||
private func toggleDubPlayback() {
|
private func toggleDubPlayback() {
|
||||||
if dubbingPlayback {
|
if dubbingPlayback {
|
||||||
stopDubPlayback(message: "已停止配音播放")
|
stopDubPlayback(message: "已停止录音播放")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let sentence = controller.currentSentence
|
guard let sentence = controller.currentSentence else {
|
||||||
let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] }
|
dubbingStatus = "请先选择要播放的句子"
|
||||||
guard let audioURL else { return }
|
return
|
||||||
if mergedDubURL == nil, let sentence = sentence {
|
|
||||||
controller.seek(to: sentence.startMs)
|
|
||||||
}
|
}
|
||||||
|
guard let audioURL = dubSegments[sentence.index] else {
|
||||||
|
dubbingStatus = "当前句还没有录音,请先录音后再播放"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Always play the current sentence recording only (never the merged full track).
|
||||||
|
stopDubPlayback(message: nil)
|
||||||
|
controller.seek(to: sentence.startMs)
|
||||||
controller.pause()
|
controller.pause()
|
||||||
controller.setVolume(0)
|
controller.setVolume(0)
|
||||||
let player = dubPlayer ?? AVPlayer()
|
let player = dubPlayer ?? AVPlayer()
|
||||||
dubPlayer = player
|
dubPlayer = player
|
||||||
player.replaceCurrentItem(with: AVPlayerItem(url: audioURL))
|
let item = AVPlayerItem(url: audioURL)
|
||||||
|
player.replaceCurrentItem(with: item)
|
||||||
dubbingPlayback = true
|
dubbingPlayback = true
|
||||||
dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音"
|
dubbingStatus = "正在播放当前句录音"
|
||||||
player.play()
|
player.play()
|
||||||
if dubPlaybackObserver == nil {
|
if let dubPlaybackObserver {
|
||||||
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
NotificationCenter.default.removeObserver(dubPlaybackObserver)
|
||||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
self.dubPlaybackObserver = nil
|
||||||
object: player.currentItem,
|
}
|
||||||
queue: .main
|
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
||||||
) { _ in
|
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
object: item,
|
||||||
stopDubPlayback(message: "配音播放完成")
|
queue: .main
|
||||||
}
|
) { _ in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||||
|
stopDubPlayback(message: "本句录音播放完成")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,7 +485,7 @@ struct TestView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isSharingDub = true
|
isSharingDub = true
|
||||||
dubbingStatus = "正在生成分享链接…"
|
dubbingStatus = "正在生成分享链接,请稍候..."
|
||||||
let request = DubShareUploadRequest(
|
let request = DubShareUploadRequest(
|
||||||
videoHash: item.id,
|
videoHash: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum Theme {
|
|||||||
static let textDark = Color(hex: 0x12181F)
|
static let textDark = Color(hex: 0x12181F)
|
||||||
static let textMuted = Color(hex: 0x97A2AE)
|
static let textMuted = Color(hex: 0x97A2AE)
|
||||||
static let textSubtle = Color(hex: 0x58626C)
|
static let textSubtle = Color(hex: 0x58626C)
|
||||||
|
static let danger = Color(hex: 0xDC2626)
|
||||||
static let textOverlay = Color(hex: 0xA3ACB6)
|
static let textOverlay = Color(hex: 0xA3ACB6)
|
||||||
|
|
||||||
static func formatTime(_ ms: Int64) -> String {
|
static func formatTime(_ ms: Int64) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user