added share function

This commit is contained in:
2026-08-26 09:47:52 +08:00
parent ccd8ec16bb
commit f80da8a5f6
5 changed files with 435 additions and 18 deletions

View File

@@ -22,8 +22,10 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.StrictMode
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
@@ -57,8 +59,12 @@ import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
import java.io.File
import java.io.DataOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.util.Locale
import org.json.JSONObject
import androidx.core.content.FileProvider
import kotlin.math.max
import androidx.media3.common.MediaItem
@@ -95,6 +101,7 @@ class MainActivity : Activity() {
private var recordingFile: File? = null
private val dubSegments = mutableMapOf<Int, File>()
private var mergedDubFile: File? = null
private var dubbingStatusText: TextView? = null
private var dubbingPlayback = false
private var activeItemId: String = ""
@@ -712,6 +719,12 @@ class MainActivity : Activity() {
setLineSpacing(4.dp.toFloat(), 1f)
setPadding(0, 8.dp, 0, 0)
}
dubbingStatusText = TextView(this).apply {
setTextColor(COLOR_ACCENT_DEEP)
textSize = 13f
setPadding(0, 10.dp, 0, 0)
text = "配音:尚未生成"
}
val sentenceButton = { label: String, action: () -> Unit ->
Button(this).apply {
@@ -840,9 +853,14 @@ class MainActivity : Activity() {
})
addView(scoreSummaryText)
addView(scoreDetailText)
addView(dubbingStatusText)
}
}
private fun setDubbingStatus(message: String) {
dubbingStatusText?.text = message
}
private fun refreshTestUi() {
if (!::testStatusText.isInitialized) {
return
@@ -862,6 +880,13 @@ class MainActivity : Activity() {
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
}
refreshDubButtons()
if (mergedDubFile == null) {
dubbingStatusText?.text = when (dubSegments.size) {
0 -> "配音:请先录音"
1 -> "配音:已保存 1 句,可生成配音"
else -> "配音:已保存 ${dubSegments.size} 句,可生成配音"
}
}
}
private fun applyTestSubtitleVisibility() {
@@ -976,7 +1001,7 @@ class MainActivity : Activity() {
private fun refreshDubButtons() {
playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
mergeDubButton.isEnabled = dubSegments.size >= 2 && !dubbingPlayback
mergeDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] }
playDubButton.text = when {
@@ -1049,16 +1074,19 @@ class MainActivity : Activity() {
}
private fun mergeDubSegments() {
if (dubSegments.size < 2) {
val segments = dubSegments.toSortedMap().values.toList()
if (segments.isEmpty()) {
return
}
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
var muxer: MediaMuxer? = null
try {
val muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var muxerAudioTrack = -1
var muxerStarted = false
var presentationTimeUs = 0L
dubSegments.toSortedMap().values.forEach { segment ->
segments.forEach { segment ->
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
try {
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
@@ -1069,6 +1097,8 @@ class MainActivity : Activity() {
val format = extractor.getTrackFormat(trackIndex)
if (muxerAudioTrack == -1) {
muxerAudioTrack = muxer.addTrack(format)
muxer.start()
muxerStarted = true
}
val buffer = ByteBuffer.allocateDirect(
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
@@ -1093,14 +1123,24 @@ class MainActivity : Activity() {
extractor.release()
}
}
require(muxerStarted && muxerAudioTrack != -1) { "录音没有可用音频轨道" }
muxer.stop()
muxer.release()
muxer = null
mergedDubFile = output
refreshTestUi()
statusText.text = "已合成 ${dubSegments.size} 配音${output.name}"
setDubbingStatus("已合成 ${segments.size} 配音,可点击“分享配音”")
} catch (error: Throwable) {
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
output.delete()
statusText.text = "合成失败:${error.message.orEmpty()}"
Log.e(TAG_DUBBING, "merge failed", error)
setDubbingStatus(
buildString {
append("生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
append(" · 已保存 ${dubSegments.size}")
}
)
}
}
@@ -1120,22 +1160,74 @@ class MainActivity : Activity() {
}
private fun shareMergedDubbing() {
val file = mergedDubFile?.takeIf { it.exists() } ?: return
val item = controller.currentTrainingItem()
if (item == null || !SHA256_PATTERN.matches(item.id)) {
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
return
}
try {
val uri = FileProvider.getUriForFile(
this,
"${packageName}.fileprovider",
file,
)
val shareUrl = uploadDubShare(item.id, item.title)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "audio/mp4"
putExtra(Intent.EXTRA_STREAM, uri)
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(Intent.createChooser(intent, "分享配音"))
setDubbingStatus("已生成分享链接:$shareUrl")
} catch (error: Throwable) {
statusText.text = "分享失败:${error.message.orEmpty()}"
Log.e(TAG_DUBBING, "share failed", error)
setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
}
}
private fun uploadDubShare(videoHash: String, videoTitle: String): String {
val policy = StrictMode.getThreadPolicy()
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
val boundary = "oral-trainer-${System.currentTimeMillis()}"
var connection: HttpURLConnection? = null
try {
connection = URL(URL(SERVER_BASE_URL), "/api/v1/dub-shares").openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
DataOutputStream(connection.outputStream).use { output ->
fun text(name: String, value: String) {
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
}
text("video_hash", videoHash.lowercase(Locale.US))
text("title", videoTitle.ifBlank { "我的口语配音" })
// Build explicitly to avoid fragile string replacement.
val payload = dubSegments.keys.sorted().joinToString(",", "[", "]") {
"{\"sentence_index\":$it}"
}
text("segments", payload)
dubSegments.toSortedMap().forEach { (index, file) ->
output.writeBytes("--$boundary\r\n")
output.writeBytes(
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
)
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
file.inputStream().use { input -> input.copyTo(output) }
output.writeBytes("\r\n")
}
output.writeBytes("--$boundary--\r\n")
}
val code = connection.responseCode
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
require(code in 200..299) { "HTTP $code: $body" }
val response = JSONObject(body).getString("share_id")
return "$SERVER_BASE_URL/dub-shares/$response"
} finally {
connection?.disconnect()
StrictMode.setThreadPolicy(policy)
}
}
@@ -1511,6 +1603,7 @@ class MainActivity : Activity() {
const val PICK_VIDEO_REQUEST = 1001
const val RECORD_AUDIO_REQUEST = 1002
const val PROGRESS_MAX = 1000
private const val TAG_DUBBING = "Dubbing"
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")