Skip to content

Commit

Permalink
[Feat]: subtitle library 更改
Browse files Browse the repository at this point in the history
  • Loading branch information
why committed Apr 18, 2024
1 parent 5eb7019 commit 3d132ff
Show file tree
Hide file tree
Showing 10 changed files with 506 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package com.xiaoyv.bangumi.special.subtitle

import android.content.Intent
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper
import androidx.lifecycle.LifecycleOwner
import com.blankj.utilcode.util.ActivityUtils
import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.UriUtils
import com.xiaoyv.bangumi.databinding.ActivitySubtitleToolBinding
import com.xiaoyv.blueprint.base.mvvm.normal.BaseViewModelActivity
import com.xiaoyv.common.config.annotation.SubtitleActionType
import com.xiaoyv.common.kts.debugLog
import com.xiaoyv.common.kts.initNavBack
import com.xiaoyv.common.kts.showOptionsDialog
import com.xiaoyv.common.widget.dialog.AnimeLoadingDialog
import com.xiaoyv.subtitle.media.entity.FFProbeEntity
import com.xiaoyv.widget.callback.setOnFastLimitClickListener
import com.xiaoyv.widget.dialog.UiDialog

Expand All @@ -15,7 +24,7 @@ class SubtitleToolActivity :

private val selectSubtitleLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) {
viewModel.loadSubtitleFromMedia(it ?: return@registerForActivityResult)
viewModel.loadMediaStreamInfo(it ?: return@registerForActivityResult)
}

override fun initView() {
Expand All @@ -32,6 +41,76 @@ class SubtitleToolActivity :
}
}

override fun LifecycleOwner.initViewObserver() {
viewModel.onStreamInfoLiveData.observe(this) {
val streams = it.orEmpty()
if (streams.isNotEmpty()) {
showOptionsDialog(
title = String.format("内挂 %d 组字幕", streams.size),
items = streams.map { stream -> stream.displayTitle },
onItemClick = { item, index ->
showSubtitleActionOptions(item, streams[index])
}
)
}
}

viewModel.onSubtitleLiveData.observe(this) {
val result = it ?: return@observe
when (result.actionType) {
// 复制内容
SubtitleActionType.TYPE_COPY -> {
ClipboardUtils.copyText(result.file.readText())
showToast("复制成功")
}
// 分享导出
SubtitleActionType.TYPE_SHARE -> {
var intent = Intent(Intent.ACTION_SEND)
intent.setType("text/*")
intent.putExtra(Intent.EXTRA_STREAM, UriUtils.file2Uri(result.file))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent = Intent.createChooser(intent, "导出字幕文件")
ActivityUtils.startActivity(intent)
}
// 翻译字幕
SubtitleActionType.TYPE_TRANSLATE -> {
viewModel.translateSubtitle(result)
}
}
}

// 翻译进度
viewModel.onTranslateProgress.observe(this) {
debugLog {
String.format(
"翻译进度:%d/%d, %.2f%%",
it.first,
it.second,
it.first * 100f / it.second.toFloat()
)
}
}
}

/**
* 操作选项
*/
private fun showSubtitleActionOptions(item: String, stream: FFProbeEntity.Stream) {
val actions = mapOf(
SubtitleActionType.TYPE_SHARE to "分享导出",
SubtitleActionType.TYPE_COPY to "复制内容",
SubtitleActionType.TYPE_TRANSLATE to "一键翻译"
)

showOptionsDialog(
title = "字幕:$item",
items = actions.values.toList(),
onItemClick = { _, index ->
viewModel.extractSubtitle(actions.keys.toList()[index], stream)
}
)
}

override fun onCreateLoadingDialog(): UiDialog {
return AnimeLoadingDialog(this)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,186 @@
package com.xiaoyv.bangumi.special.subtitle

import android.net.Uri
import androidx.lifecycle.MutableLiveData
import com.blankj.utilcode.util.FileIOUtils
import com.blankj.utilcode.util.PathUtils
import com.xiaoyv.blueprint.base.mvvm.normal.BaseViewModel
import com.xiaoyv.blueprint.kts.launchUI
import com.xiaoyv.common.api.BgmApiManager
import com.xiaoyv.common.api.request.MicrosoftTranslateParam
import com.xiaoyv.common.config.annotation.SubtitleActionType
import com.xiaoyv.common.config.bean.SubtitleResult
import com.xiaoyv.common.helper.UserTokenHelper
import com.xiaoyv.common.kts.debugLog
import com.xiaoyv.subtitle.api.parser.ParserFactory
import com.xiaoyv.subtitle.api.subtitle.common.SubtitleLine
import com.xiaoyv.subtitle.api.subtitle.common.TimedLine
import com.xiaoyv.subtitle.media.MediaSubtitleExtractor
import com.xiaoyv.subtitle.media.entity.FFProbeEntity
import com.xiaoyv.widget.kts.errorMsg
import com.xiaoyv.widget.kts.sendValue
import com.xiaoyv.widget.kts.showToastCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicInteger

class SubtitleToolViewModel : BaseViewModel() {
internal val onStreamInfoLiveData = MutableLiveData<List<FFProbeEntity.Stream>?>()
internal val onSubtitleLiveData = MutableLiveData<SubtitleResult?>()
internal val onTranslateProgress = MutableLiveData<Pair<Int, Int>>()

fun loadSubtitleFromMedia(uri: Uri) {
launchUI(error = {
it.printStackTrace()
private var extractor: MediaSubtitleExtractor? = null

}) {
withContext(Dispatchers.IO) {
/**
* 翻译时,剔除样式数据
*/
private val regex = "\\{[\\s\\S]+?\\}"

fun loadMediaStreamInfo(uri: Uri) {
launchUI(
error = {
it.printStackTrace()

showToastCompat(it.errorMsg)

onStreamInfoLiveData.value = null
},
block = {
onStreamInfoLiveData.value = withContext(Dispatchers.IO) {
extractor = MediaSubtitleExtractor.from(context, uri)
val entity = extractor?.extractStreamInfo()
val streams = entity?.streams.orEmpty()
check(streams.isNotEmpty()) { "当前文件没有内挂任何字幕" }
streams
}
}
)
}

/**
* 抽取字幕并解析
*/
fun extractSubtitle(@SubtitleActionType action: Int, stream: FFProbeEntity.Stream) {
launchUI(
state = loadingDialogState(cancelable = false),
error = {
it.printStackTrace()

showToastCompat(it.errorMsg)

onSubtitleLiveData.value = null
},
block = {
val subtitleExtractor = requireNotNull(extractor)
val saveDir = PathUtils.getFilesPathExternalFirst() + "/subtitle"

onSubtitleLiveData.value = withContext(Dispatchers.IO) {
val subtitle = subtitleExtractor.extractSubtitle(stream, saveDir)

SubtitleResult(
file = subtitle,
timedTextFile = ParserFactory
.getParser(subtitle.extension)
.parse(subtitle),
actionType = action
)
}
}
)
}

/**
* 翻译字幕
*/
fun translateSubtitle(result: SubtitleResult) {
launchUI(
state = loadingDialogState(cancelable = false),
error = {
it.printStackTrace()

showToastCompat(it.errorMsg)

onSubtitleLiveData.value = null
},
block = {
withContext(Dispatchers.IO) {
val subtitle = result.timedTextFile
val needTranslateTimedLine = arrayListOf<TimedLine>()
// 遍历每一个字幕帧
subtitle.getTimedLines().forEach {
needTranslateTimedLine.add(it)
}

// 分组并发翻译
val total = AtomicInteger(needTranslateTimedLine.size)
val current = AtomicInteger(0)
val group = 10
val chunkedCount = (total.get() / group).let { if (it == 0) 1 else it }
val tasks = needTranslateTimedLine.chunked(chunkedCount).map {
async(Dispatchers.IO) {
it.forEach { item ->
runCatching { translateSubtitleTimeline(item) }
onTranslateProgress.sendValue(current.incrementAndGet() to total.get())
}
}
}
awaitAll(*tasks.toTypedArray())

// 写入文件
val translateFile = File(
result.file.parent,
result.file.nameWithoutExtension + "-zh-CN." + result.file.extension
)
FileIOUtils.writeFileFromString(translateFile, subtitle.toString())

debugLog { "翻译完成!$translateFile" }
}
}
)
}

/**
* 翻译一个字幕帧
*/
private suspend fun translateSubtitleTimeline(timedLine: TimedLine) {
if (timedLine !is SubtitleLine<*>) return

// 原文为 KEY,译文为 VALUE
val originalToTranslateMap: MutableMap<String, String> = timedLine.getTextLines()
.associate { it.replace(regex.toRegex(), "") to "" }
.toMutableMap()

// 原文
val original = originalToTranslateMap.keys.toList()

// 译文,顺序和原文一一对应
val translateResult = BgmApiManager.bgmJsonApi.postMicrosoftTranslate(
authentication = "Bearer ${UserTokenHelper.queryMicrosoftToken()}",
param = original.map { MicrosoftTranslateParam(text = it) }
).map { it.translations?.firstOrNull()?.text.orEmpty() }

// 更新 MAP 映射表的译文
original.forEach {
val originalIndex = original.indexOf(it)
originalToTranslateMap[it] = translateResult[originalIndex]
}

// 将翻译结果一一替换
val newTextLines = timedLine.getTextLines().map {
var result = it
originalToTranslateMap.forEach { (key, value) ->
if (it.contains(key)) {
result = it.replace(key, value)
}
}
result
}

// 设置翻译后的数据
timedLine.setTextLines(ArrayList(newTextLines))
}


Expand Down Expand Up @@ -56,4 +220,6 @@ class SubtitleToolViewModel : BaseViewModel() {
}
}*/


}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.xiaoyv.common.api.request.MicrosoftTranslateParam
import com.xiaoyv.common.api.response.MicrosoftJwtPayload
import com.xiaoyv.common.helper.CacheHelper
import com.xiaoyv.common.helper.ConfigHelper
import com.xiaoyv.common.helper.UserTokenHelper
import com.xiaoyv.common.kts.fromJson
import com.xiaoyv.common.kts.randId
import com.xiaoyv.widget.kts.errorMsg
Expand Down Expand Up @@ -112,7 +113,7 @@ class SummaryViewModel : BaseViewModel() {
private suspend fun doTranslateWithMicrosoft(): String {
return withContext(Dispatchers.IO) {
val translateText = needTranslateText
val microsoftToken = queryMicrosoftToken()
val microsoftToken = UserTokenHelper.queryMicrosoftToken()
val translate = BgmApiManager.bgmJsonApi.postMicrosoftTranslate(
authentication = "Bearer $microsoftToken",
param = listOf(MicrosoftTranslateParam(text = translateText))
Expand Down Expand Up @@ -155,25 +156,6 @@ class SummaryViewModel : BaseViewModel() {
}
}

private suspend fun queryMicrosoftToken(): String {
return withContext(Dispatchers.IO) {
val edgeAuthToken = ConfigHelper.edgeAuthToken
if (edgeAuthToken.isNotBlank()) {
val orEmpty = edgeAuthToken.split(".").getOrNull(1).orEmpty()
val jwtJson = EncodeUtils.base64Decode(orEmpty).decodeToString()
val payload = jwtJson.fromJson<MicrosoftJwtPayload>()
val expirationTime = payload?.expirationTime.orEmpty() * 1000L
if (expirationTime > System.currentTimeMillis()) {
return@withContext edgeAuthToken
}
}

requireNotNull(BgmApiManager.bgmJsonApi.queryEdgeAuthToken().body()).string().apply {
ConfigHelper.edgeAuthToken = this
}
}
}

private fun generateSign(text: String, appId: String, secret: String, salt: String): String {
return EncryptUtils.encryptMD5ToString("$appId$text$salt$secret").lowercase()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.xiaoyv.common.config.annotation

import androidx.annotation.IntDef

/**
* Class: [SubtitleActionType]
*
* @author why
* @since 11/25/23
*/
@IntDef(
SubtitleActionType.TYPE_SHARE,
SubtitleActionType.TYPE_COPY,
SubtitleActionType.TYPE_TRANSLATE,
)
@Retention(AnnotationRetention.SOURCE)
annotation class SubtitleActionType {
companion object {
const val TYPE_SHARE = 1
const val TYPE_COPY = 2
const val TYPE_TRANSLATE = 3
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.xiaoyv.common.config.bean

import android.os.Parcelable
import com.xiaoyv.common.config.annotation.SubtitleActionType
import com.xiaoyv.subtitle.api.subtitle.common.TimedLine
import com.xiaoyv.subtitle.api.subtitle.common.TimedTextFile
import kotlinx.parcelize.Parcelize
import java.io.File

@Parcelize
data class SubtitleResult(
var file: File,
var timedTextFile: TimedTextFile<out TimedLine>,
@SubtitleActionType var actionType: Int
) : Parcelable
Loading

0 comments on commit 3d132ff

Please sign in to comment.