added share function
This commit is contained in:
@@ -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}$")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user