...
This commit is contained in:
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
@@ -381,6 +382,45 @@ class CompletedFilesFragment : Fragment() {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun copyToDownloadFolder(filesToCopy: List<File>) {
|
||||
if (filesToCopy.isEmpty()) return
|
||||
|
||||
// 1. 기기의 공용 Download 폴더 경로 가져오기
|
||||
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
// 다운로드 폴더 안에 'Lunatic'이라는 앱 전용 폴더를 만들어 깔끔하게 모아둡니다.
|
||||
val targetDir = File(downloadDir, "Lunatic_Downloads")
|
||||
if (!targetDir.exists()) targetDir.mkdirs()
|
||||
|
||||
Toast.makeText(context, "다운로드 폴더로 복사를 시작합니다...", Toast.LENGTH_SHORT).show()
|
||||
|
||||
// 2. 용량이 클 수 있으므로 백그라운드 스레드에서 복사 진행
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
var copyCount = 0
|
||||
filesToCopy.forEach { file ->
|
||||
try {
|
||||
val destFile = File(targetDir, file.name)
|
||||
if (file.isDirectory) {
|
||||
// 폴더일 경우 내부 파일까지 통째로 복사
|
||||
file.copyRecursively(destFile, overwrite = true)
|
||||
} else {
|
||||
// 단일 파일 복사
|
||||
file.copyTo(destFile, overwrite = true)
|
||||
}
|
||||
copyCount++
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 복사 완료 후 UI 스레드에서 결과 알림
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(requireContext(), "${copyCount}개 항목 복사 완료\n(Download/Lunatic_Downloads)", Toast.LENGTH_LONG).show()
|
||||
toggleSelectionMode(false) // 선택 모드 해제
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 선택한 파일 이동 다이얼로그
|
||||
private fun showMoveDialog() {
|
||||
if (selectedFiles.isEmpty()) return
|
||||
@@ -477,6 +517,18 @@ class CompletedFilesFragment : Fragment() {
|
||||
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
||||
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
||||
view.findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
|
||||
view.findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
|
||||
if (selectedFiles.isEmpty()) return@setOnClickListener
|
||||
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("다운로드 폴더로 내보내기")
|
||||
.setMessage("선택한 ${selectedFiles.size}개 항목을 기기의 다운로드 폴더(Lunatic_Downloads)로 복사하시겠습니까?")
|
||||
.setPositiveButton("복사") { _, _ ->
|
||||
copyToDownloadFolder(selectedFiles.toList())
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
view.findViewById<View>(R.id.btnDeleteSelected)?.setOnClickListener {
|
||||
if (selectedFiles.isEmpty()) return@setOnClickListener
|
||||
|
||||
|
||||
@@ -766,14 +766,28 @@ open class GeckoWeb @JvmOverloads constructor(
|
||||
|
||||
// Dialog Helpers
|
||||
private fun showNewSessionDialog(uri: String) {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("Move To\n$uri")
|
||||
.setPositiveButton("브라우저로 이동") { _, _ ->
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply { flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP })
|
||||
if (uri.startsWith("magnet:?")) {
|
||||
val intent = Intent(context, TorrentService::class.java).apply {
|
||||
putExtra("EXTRA_MAGNET_URI", uri)
|
||||
}
|
||||
.setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
} else {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("Move To\n$uri")
|
||||
.setPositiveButton("브라우저로 이동") { _, _ ->
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
|
||||
flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP
|
||||
})
|
||||
}
|
||||
.setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showContextMenu(pageUrl: String, mediaUrl: String) {
|
||||
|
||||
@@ -54,6 +54,8 @@ import bums.lunatic.launcher.home.tokiz.TokiFragment
|
||||
import bums.lunatic.launcher.utils.Blog
|
||||
import bums.lunatic.launcher.utils.beforeDay
|
||||
import bums.lunatic.launcher.workers.WorkersDb
|
||||
import com.google.android.gms.wearable.MessageClient
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.yausername.ffmpeg.FFmpeg
|
||||
import com.yausername.youtubedl_android.YoutubeDL
|
||||
@@ -121,6 +123,64 @@ open class NeoRssActivity : CommonActivity() {
|
||||
var lastAction = MotionEvent.ACTION_HOVER_EXIT
|
||||
|
||||
|
||||
private val messageListener = MessageClient.OnMessageReceivedListener { messageEvent ->
|
||||
val currentFragment = targetFragment ?: supportFragmentManager.fragments.find { it.isVisible }
|
||||
if (currentFragment is RemoteGestureFragment && currentFragment.isRemoteEnabled) {
|
||||
// when(keyType) {
|
||||
// "UP_ARROW"->{currentFragment.onRemoteUp(isDouble)}
|
||||
// "DOWN_ARROW"->{currentFragment.onRemoteDown(isDouble)}
|
||||
//
|
||||
// "LEFT_ARROW"->{currentFragment.onRemoteLeft(isDouble)}
|
||||
// "CENTER_SINGLE_TAP" -> { currentFragment.onRemoteCenterClick() }
|
||||
// "CENTER_DOUBLE_TAP" -> { currentFragment.onRemoteCenterDoubleClick() }
|
||||
// }
|
||||
// when(currentFragment) {
|
||||
// is RssHome ->{
|
||||
// if (currentFragment.binding.layoutRssSummary.root.isVisible) {
|
||||
// currentFragment.openGecko(rssData = currentFragment.randomOrNull())
|
||||
// } else {
|
||||
// currentFragment.doNextPage()
|
||||
// }
|
||||
// }
|
||||
// is TokiFragment -> {
|
||||
// currentFragment.back()
|
||||
// }
|
||||
// is CompletedFilesFragment -> {
|
||||
// currentFragment.backPress()
|
||||
// }
|
||||
// else -> {
|
||||
//// showContents(R.id.close)
|
||||
// }
|
||||
// }
|
||||
when (messageEvent.path) {
|
||||
"/gesture/next" -> {
|
||||
when(currentFragment) {
|
||||
is RssHome ->{
|
||||
if (currentFragment.binding.layoutRssSummary.root.isVisible) {
|
||||
currentFragment.openGecko(rssData = currentFragment.randomOrNull())
|
||||
} else {
|
||||
currentFragment.doNextPage()
|
||||
}
|
||||
}
|
||||
is TokiFragment -> {
|
||||
currentFragment.back()
|
||||
}
|
||||
is CompletedFilesFragment -> {
|
||||
currentFragment.backPress()
|
||||
}
|
||||
else -> {
|
||||
// showContents(R.id.close)
|
||||
}
|
||||
}
|
||||
}
|
||||
"/gesture/prev" -> {
|
||||
currentFragment.onRemoteLeft(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onKeyLongPress(keyCode: Int, ev: KeyEvent?): Boolean {
|
||||
Blog.LOGE("keyEvent >>>>> ${ev?.device?.name}:${keyCode}: onKeyLongPress >>> ${ev} ")
|
||||
return super.onKeyLongPress(keyCode, ev)
|
||||
@@ -494,7 +554,7 @@ open class NeoRssActivity : CommonActivity() {
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
Wearable.getMessageClient(this).removeListener(messageListener)
|
||||
}
|
||||
|
||||
|
||||
@@ -545,7 +605,10 @@ open class NeoRssActivity : CommonActivity() {
|
||||
R.id.btn_x -> TokiFragment.newInstanceX()
|
||||
R.id.btn_i -> TokiFragment.newInstanceI()
|
||||
R.id.btn_btsearch -> TokiFragment.newInstanceMagnet()
|
||||
// R.id.btn_img4 -> TokiFragment.newInstanceTumblr()
|
||||
R.id.btn_img4 -> {
|
||||
startActivity(Intent(this@NeoRssActivity, TranslatorActivity::class.java))
|
||||
targetFragment
|
||||
}
|
||||
R.id.btn_img3 -> TokiFragment.newInstanceTumblr()
|
||||
// R.id.btn_img2 -> TokiFragment.newInstancePixiv()
|
||||
// R.id.btn_img1 -> TokiFragment.newInstanceArtStation()
|
||||
@@ -605,6 +668,7 @@ open class NeoRssActivity : CommonActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Blog.LOGE("LauncherActivity onResume")
|
||||
Wearable.getMessageClient(this).addListener(messageListener)
|
||||
}
|
||||
|
||||
private fun openSearch() {
|
||||
|
||||
@@ -739,7 +739,7 @@ internal class RssHome : RemoteGestureFragment() , KeyEventHandler {
|
||||
// 일반 WebView라면: webView.onPause() 및 webView.pauseTimers()
|
||||
} else {
|
||||
// 💡 다시 나타날 때: 다시 시작
|
||||
// binding.geckoWeb?.onResume()
|
||||
binding.lunaticBrowser.geckoWeb?.onResume()
|
||||
// 일반 WebView라면: webView.onResume() 및 webView.resumeTimers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package bums.lunatic.launcher.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import bums.lunatic.launcher.R
|
||||
import com.google.mlkit.common.model.DownloadConditions
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||
import com.google.mlkit.nl.translate.Translation
|
||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||
import java.util.*
|
||||
|
||||
class TranslatorActivity : AppCompatActivity(), TextToSpeech.OnInitListener {
|
||||
|
||||
private lateinit var etInputText: EditText
|
||||
private lateinit var spinnerTargetLang: Spinner
|
||||
private lateinit var btnTranslate: Button
|
||||
private lateinit var tvOutputText: TextView
|
||||
private lateinit var layoutProgress: LinearLayout
|
||||
private lateinit var tvSwap: TextView
|
||||
private lateinit var tvTtsInput: TextView
|
||||
private lateinit var tvTtsOutput: TextView
|
||||
private lateinit var sbTtsSpeed: SeekBar
|
||||
private lateinit var tvInputLangStatus: TextView
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private var detectedSourceLangCode: String? = null
|
||||
private var ttsSpeed: Float = 1.0f
|
||||
|
||||
private val targetLanguages = listOf(
|
||||
Pair("한국어 🇰🇷", TranslateLanguage.KOREAN),
|
||||
Pair("영어 🇺🇸", TranslateLanguage.ENGLISH),
|
||||
Pair("일본어 🇯🇵", TranslateLanguage.JAPANESE),
|
||||
Pair("중국어 🇨🇳", TranslateLanguage.CHINESE),
|
||||
Pair("스페인어 🇪🇸", TranslateLanguage.SPANISH),
|
||||
Pair("프랑스어 🇫🇷", TranslateLanguage.FRENCH)
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_translator)
|
||||
|
||||
tts = TextToSpeech(this, this)
|
||||
initViews()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
etInputText = findViewById(R.id.etInputText)
|
||||
spinnerTargetLang = findViewById(R.id.spinnerTargetLang)
|
||||
btnTranslate = findViewById(R.id.btnTranslate)
|
||||
tvOutputText = findViewById(R.id.tvOutputText)
|
||||
layoutProgress = findViewById(R.id.layoutProgress)
|
||||
tvSwap = findViewById(R.id.tvSwap)
|
||||
tvTtsInput = findViewById(R.id.tvTtsInput)
|
||||
tvTtsOutput = findViewById(R.id.tvTtsOutput)
|
||||
sbTtsSpeed = findViewById(R.id.sbTtsSpeed)
|
||||
tvInputLangStatus = findViewById(R.id.tvInputLangStatus)
|
||||
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, targetLanguages.map { it.first })
|
||||
spinnerTargetLang.adapter = adapter
|
||||
spinnerTargetLang.setSelection(1) // 기본 영어
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
btnTranslate.setOnClickListener {
|
||||
val text = etInputText.text.toString().trim()
|
||||
if (text.isNotEmpty()) {
|
||||
val targetCode = targetLanguages[spinnerTargetLang.selectedItemPosition].second
|
||||
detectAndTranslate(text, targetCode)
|
||||
}
|
||||
}
|
||||
|
||||
tvSwap.setOnClickListener {
|
||||
val input = etInputText.text.toString()
|
||||
val output = tvOutputText.text.toString()
|
||||
if (output.isNotEmpty() && !output.startsWith("번역")) {
|
||||
etInputText.setText(output)
|
||||
tvOutputText.text = input
|
||||
detectedSourceLangCode?.let { source ->
|
||||
val idx = targetLanguages.indexOfFirst { it.second == source }
|
||||
if (idx != -1) spinnerTargetLang.setSelection(idx)
|
||||
}
|
||||
detectedSourceLangCode = null // 스왑 후에는 다시 감지 필요
|
||||
}
|
||||
}
|
||||
|
||||
sbTtsSpeed.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
// 0~200 범위를 0.1~2.0배속으로 변환 (progress 100이 1.0배속)
|
||||
ttsSpeed = progress / 100f
|
||||
if (ttsSpeed < 0.1f) ttsSpeed = 0.1f
|
||||
tts?.setSpeechRate(ttsSpeed)
|
||||
}
|
||||
override fun onStartTrackingTouch(p0: SeekBar?) {}
|
||||
override fun onStopTrackingTouch(p0: SeekBar?) {}
|
||||
})
|
||||
|
||||
tvTtsInput.setOnClickListener { playTts(etInputText.text.toString(), detectedSourceLangCode) }
|
||||
tvTtsOutput.setOnClickListener {
|
||||
val code = targetLanguages[spinnerTargetLang.selectedItemPosition].second
|
||||
playTts(tvOutputText.text.toString(), code)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectAndTranslate(text: String, targetLangCode: String) {
|
||||
tvOutputText.text = "언어 감지 중..."
|
||||
btnTranslate.isEnabled = false
|
||||
|
||||
LanguageIdentification.getClient().identifyLanguage(text)
|
||||
.addOnSuccessListener { languageCode ->
|
||||
detectedSourceLangCode = TranslateLanguage.fromLanguageTag(languageCode)
|
||||
if (detectedSourceLangCode != null && languageCode != "und") {
|
||||
tvInputLangStatus.text = "원본: ${languageCode.uppercase()}"
|
||||
tvTtsInput.visibility = View.VISIBLE
|
||||
performTranslation(text, detectedSourceLangCode!!, targetLangCode)
|
||||
} else {
|
||||
tvOutputText.text = "언어를 감지할 수 없습니다."
|
||||
btnTranslate.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performTranslation(text: String, source: String, target: String) {
|
||||
if (source == target) {
|
||||
tvOutputText.text = text
|
||||
btnTranslate.isEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
layoutProgress.visibility = View.VISIBLE
|
||||
val options = TranslatorOptions.Builder().setSourceLanguage(source).setTargetLanguage(target).build()
|
||||
val translator = Translation.getClient(options)
|
||||
|
||||
translator.downloadModelIfNeeded(DownloadConditions.Builder().build())
|
||||
.addOnSuccessListener {
|
||||
layoutProgress.visibility = View.GONE
|
||||
translator.translate(text)
|
||||
.addOnSuccessListener { translated ->
|
||||
tvOutputText.text = translated
|
||||
tvTtsOutput.visibility = View.VISIBLE
|
||||
btnTranslate.isEnabled = true
|
||||
}
|
||||
.addOnFailureListener { btnTranslate.isEnabled = true }
|
||||
}
|
||||
.addOnFailureListener {
|
||||
layoutProgress.visibility = View.GONE
|
||||
btnTranslate.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun playTts(text: String, langCode: String?) {
|
||||
if (text.isEmpty() || langCode == null) return
|
||||
val locale = when (langCode) {
|
||||
TranslateLanguage.KOREAN -> Locale.KOREAN
|
||||
TranslateLanguage.ENGLISH -> Locale.ENGLISH
|
||||
TranslateLanguage.JAPANESE -> Locale.JAPANESE
|
||||
TranslateLanguage.CHINESE -> Locale.CHINESE
|
||||
else -> Locale.getDefault()
|
||||
}
|
||||
tts?.apply {
|
||||
language = locale
|
||||
setSpeechRate(ttsSpeed) // 현재 SeekBar에 설정된 속도 적용
|
||||
speak(text, TextToSpeech.QUEUE_FLUSH, null, "ID")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) Log.d("TTS", "Ready") }
|
||||
override fun onDestroy() { tts?.stop(); tts?.shutdown(); super.onDestroy() }
|
||||
}
|
||||
@@ -7,17 +7,22 @@ class NativePlayer {
|
||||
private var subtitleCallback: ((String) -> Unit)? = null
|
||||
private var videoSizeCallback: ((Int, Int) -> Unit)? = null
|
||||
|
||||
// 💡 JNI로부터 받을 콜백 리스너 설정
|
||||
var onPreparedListener: (() -> Unit)? = null
|
||||
var onErrorListener: ((Int, String) -> Unit)? = null
|
||||
|
||||
fun initialize(): Boolean {
|
||||
nativeHandle = nativeInit()
|
||||
return nativeHandle != 0L
|
||||
}
|
||||
|
||||
fun setDataSource(videoFd: Int, subPath: String) = nativeSetDataSource(nativeHandle, videoFd, subPath)
|
||||
fun setDataSource(videoFd: Int, subFd: Int) = nativeSetDataSource(nativeHandle, videoFd, subFd)
|
||||
fun prepareAsync() { if (nativeHandle != 0L) nativePrepareAsync(nativeHandle) }
|
||||
fun play(surface: Surface) = nativePlay(nativeHandle, surface)
|
||||
fun pause() { if (nativeHandle != 0L) nativePause(nativeHandle) }
|
||||
fun stop() = nativeStop(nativeHandle)
|
||||
fun seekBy(sec: Double) = nativeSeekBy(nativeHandle, sec)
|
||||
fun setSpeed(speed: Float) = nativeSetSpeed(nativeHandle, speed)
|
||||
fun pause() {nativePause(nativeHandle)}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun onSubtitleTextDecoded(text: String) {
|
||||
@@ -29,6 +34,18 @@ class NativePlayer {
|
||||
videoSizeCallback?.invoke(w, h)
|
||||
}
|
||||
|
||||
// 💡 C++에서 준비 완료 시 호출
|
||||
@Suppress("unused")
|
||||
private fun onNativePrepared() {
|
||||
onPreparedListener?.invoke()
|
||||
}
|
||||
|
||||
// 💡 C++에서 오류 발생 시 호출
|
||||
@Suppress("unused")
|
||||
private fun onNativeError(errorCode: Int, errorMessage: String) {
|
||||
onErrorListener?.invoke(errorCode, errorMessage)
|
||||
}
|
||||
|
||||
fun setSubtitleCallback(cb: (String) -> Unit) { subtitleCallback = cb }
|
||||
fun setVideoSizeCallback(cb: (Int, Int) -> Unit) { videoSizeCallback = cb }
|
||||
|
||||
@@ -38,14 +55,42 @@ class NativePlayer {
|
||||
nativeHandle = 0L
|
||||
}
|
||||
}
|
||||
private external fun nativePause(h: Long)
|
||||
|
||||
data class SubtitleTrack(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val isExternal: Boolean
|
||||
)
|
||||
|
||||
fun getInternalSubtitleTracks(): List<SubtitleTrack> {
|
||||
if (nativeHandle == 0L) return emptyList()
|
||||
val rawStr = nativeGetSubtitleTracks(nativeHandle) ?: return emptyList()
|
||||
return rawStr.split(",").filter { it.isNotBlank() }.mapNotNull {
|
||||
val parts = it.split(":")
|
||||
if (parts.size >= 2) SubtitleTrack(parts[0], parts[1], false) else null
|
||||
}
|
||||
}
|
||||
|
||||
fun setInternalSubtitleTrack(streamIndex: Int) {
|
||||
if (nativeHandle != 0L) nativeSetSubtitleTrack(nativeHandle, streamIndex)
|
||||
}
|
||||
|
||||
fun getCurrentPosition(): Double {
|
||||
return if (nativeHandle != 0L) nativeGetCurrentPosition(nativeHandle) else 0.0
|
||||
}
|
||||
|
||||
private external fun nativeInit(): Long
|
||||
private external fun nativeSetDataSource(h: Long, fd: Int, sub: String)
|
||||
private external fun nativeSetDataSource(h: Long, videoFd: Int, subFd: Int)
|
||||
private external fun nativePrepareAsync(h: Long)
|
||||
private external fun nativePlay(h: Long, s: Surface)
|
||||
private external fun nativePause(h: Long)
|
||||
private external fun nativeStop(h: Long)
|
||||
private external fun nativeDestroy(h: Long)
|
||||
private external fun nativeSeekBy(h: Long, s: Double)
|
||||
private external fun nativeSetSpeed(h: Long, sp: Float)
|
||||
private external fun nativeGetCurrentPosition(h: Long): Double
|
||||
private external fun nativeGetSubtitleTracks(h: Long): String
|
||||
private external fun nativeSetSubtitleTrack(h: Long, index: Int)
|
||||
|
||||
companion object {
|
||||
init { System.loadLibrary("native_renderer") }
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package bums.lunatic.launcher.player
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Color
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
@@ -10,12 +12,28 @@ import android.view.*
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import bums.lunatic.launcher.R
|
||||
import bums.lunatic.launcher.player.NativePlayer.SubtitleTrack
|
||||
import bums.lunatic.launcher.utils.Blog
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||
import com.google.mlkit.nl.translate.Translation
|
||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.CodingErrorAction
|
||||
|
||||
class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
data class SubtitleBlock(
|
||||
val startSec: Double,
|
||||
val endSec: Double,
|
||||
val text: String,
|
||||
var translatedText: String? = null // 💡 번역본이 저장될 공간
|
||||
)
|
||||
|
||||
private lateinit var videoTextureView: TextureView
|
||||
private lateinit var subtitleView: TextView
|
||||
@@ -33,48 +51,74 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
|
||||
|
||||
private var externalSubtitles = listOf<SubtitleBlock>()
|
||||
private var subtitleSyncJob: Job? = null
|
||||
private val allSubtitleTracks = mutableListOf<SubtitleTrack>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 1. 데이터 확인
|
||||
videoPath = intent.getStringExtra("VIDEO_PATH") ?: ""
|
||||
if (videoPath.isEmpty()) { finish(); return }
|
||||
subtitlePath = findSubtitleFile(videoPath)
|
||||
|
||||
// 2. UI 동적 생성 및 구성
|
||||
subtitlePath = findSubtitleFile(videoPath)
|
||||
if (subtitlePath.isNotEmpty()) {
|
||||
externalSubtitles = parseSrt(File(subtitlePath))
|
||||
detectLanguageAndTranslate()
|
||||
}
|
||||
|
||||
setupUI()
|
||||
|
||||
// 3. 네이티브 플레이어 초기화
|
||||
nativePlayer = NativePlayer().apply {
|
||||
initialize()
|
||||
// 영상 해상도에 따른 화면 크기 조절 콜백
|
||||
|
||||
setVideoSizeCallback { width, height ->
|
||||
videoWidth = width
|
||||
videoHeight = height
|
||||
adjustVideoAspectRatio(width, height)
|
||||
}
|
||||
// 자막 출력 콜백
|
||||
|
||||
setSubtitleCallback { text ->
|
||||
runOnUiThread {
|
||||
subtitleView.text = cleanSubtitleText(text)
|
||||
subtitleView.visibility = if (text.isEmpty()) View.INVISIBLE else View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 C++에서 준비 완료 시 호출
|
||||
onPreparedListener = {
|
||||
runOnUiThread {
|
||||
loadAvailableSubtitles()
|
||||
if (allSubtitleTracks.size > 1) {
|
||||
showSubtitleSelectionDialog()
|
||||
} else {
|
||||
// 자막 없으면 자동 시작
|
||||
play(Surface(videoTextureView.surfaceTexture!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 C++에서 오류 발생 시 호출
|
||||
onErrorListener = { errorCode, errorMessage ->
|
||||
runOnUiThread {
|
||||
Toast.makeText(this@PlayerActivity, "오류 [$errorCode]: $errorMessage", Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 제스처 설정
|
||||
setupGestures()
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
|
||||
|
||||
// 비디오 뷰
|
||||
videoTextureView = TextureView(this).apply {
|
||||
surfaceTextureListener = this@PlayerActivity
|
||||
}
|
||||
videoTextureView = TextureView(this).apply { surfaceTextureListener = this@PlayerActivity }
|
||||
|
||||
// 자막 뷰
|
||||
subtitleView = TextView(this).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 22f
|
||||
@@ -83,7 +127,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
setPadding(40, 0, 40, 180)
|
||||
}
|
||||
|
||||
// 제스처 감지용 투명 레이어 (삼등분)
|
||||
val gestureLayer = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.HORIZONTAL
|
||||
weightSum = 3f
|
||||
@@ -97,10 +140,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
gestureLayer.addView(centerZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
|
||||
gestureLayer.addView(rightZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
|
||||
|
||||
// 컨트롤 버튼 (좌측 하단 회전, 우측 하단 숨기기)
|
||||
val controls = FrameLayout(this)
|
||||
btnRotate = ImageButton(this).apply {
|
||||
setImageResource(android.R.drawable.ic_menu_rotate) // 기본 리소스 사용
|
||||
setImageResource(android.R.drawable.ic_menu_rotate)
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
setOnClickListener { toggleOrientation() }
|
||||
}
|
||||
@@ -113,8 +154,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
|
||||
root.addView(subtitleView)
|
||||
root.addView(gestureLayer)
|
||||
|
||||
// 버튼 배치
|
||||
root.addView(btnRotate, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.START).apply { setMargins(30,0,0,30) })
|
||||
root.addView(btnHideVideo, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.END).apply { setMargins(0,0,30,30) })
|
||||
|
||||
@@ -122,37 +161,89 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
hideSystemUI()
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
|
||||
val videoFile = File(videoPath)
|
||||
if (videoFile.exists()) {
|
||||
val videoPfd = ParcelFileDescriptor.open(videoFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
val videoFd = videoPfd.detachFd()
|
||||
|
||||
var subFd = -1
|
||||
if (subtitlePath.isNotEmpty()) {
|
||||
val subFile = File(subtitlePath)
|
||||
if (subFile.exists()) {
|
||||
val subPfd = ParcelFileDescriptor.open(subFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
subFd = subPfd.detachFd()
|
||||
}
|
||||
}
|
||||
|
||||
// C++로 DataSource를 넘기고 비동기 Prepare 명령!
|
||||
nativePlayer?.setDataSource(videoFd, subFd)
|
||||
nativePlayer?.prepareAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableSubtitles() {
|
||||
allSubtitleTracks.clear()
|
||||
allSubtitleTracks.add(SubtitleTrack("-1", "자막 끄기", false))
|
||||
|
||||
val internalTracks = nativePlayer?.getInternalSubtitleTracks() ?: emptyList()
|
||||
allSubtitleTracks.addAll(internalTracks)
|
||||
|
||||
if (externalSubtitles.isNotEmpty()) {
|
||||
allSubtitleTracks.add(SubtitleTrack("EXTERNAL", "외부 자막 (SRT)", true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSubtitleSelectionDialog() {
|
||||
val trackNames = allSubtitleTracks.map { it.name }.toTypedArray()
|
||||
AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert)
|
||||
.setTitle("자막 선택")
|
||||
.setCancelable(false)
|
||||
.setItems(trackNames) { _, which ->
|
||||
selectSubtitleTrack(allSubtitleTracks[which])
|
||||
nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun selectSubtitleTrack(track: SubtitleTrack) {
|
||||
if (track.isExternal) {
|
||||
nativePlayer?.setInternalSubtitleTrack(-1)
|
||||
startSubtitleSyncLoop()
|
||||
} else {
|
||||
subtitleSyncJob?.cancel()
|
||||
subtitleView.visibility = View.INVISIBLE
|
||||
nativePlayer?.setInternalSubtitleTrack(track.id.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupGestures() {
|
||||
val gestureLayer = (videoTextureView.parent as FrameLayout).getChildAt(2) as android.widget.LinearLayout
|
||||
val left = gestureLayer.getChildAt(0)
|
||||
val center = gestureLayer.getChildAt(1)
|
||||
val right = gestureLayer.getChildAt(2)
|
||||
|
||||
// 1. 가운데: 재생/일시정지
|
||||
center.setOnClickListener {
|
||||
isPlaying = !isPlaying
|
||||
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture))
|
||||
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
|
||||
else nativePlayer?.pause()
|
||||
}
|
||||
|
||||
// 2. 오른쪽: 롱프레스 4배속
|
||||
val rightDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onLongPress(e: MotionEvent) { nativePlayer?.setSpeed(4.0f) }
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
nativePlayer?.seekBy(20.0) // 탭하면 10초 앞으로
|
||||
nativePlayer?.seekBy(20.0)
|
||||
return true
|
||||
}
|
||||
})
|
||||
right.setOnTouchListener { v, event ->
|
||||
right.setOnTouchListener { _, event ->
|
||||
rightDetector.onTouchEvent(event)
|
||||
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||
nativePlayer?.setSpeed(1.0f)
|
||||
}
|
||||
true
|
||||
|
||||
}
|
||||
|
||||
// 3. 왼쪽: 롱프레스 주기적 뒤로가기
|
||||
val leftDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
leftLongPressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
@@ -163,11 +254,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
}
|
||||
}
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
nativePlayer?.seekBy(-10.0) // 탭하면 10초 뒤로
|
||||
nativePlayer?.seekBy(-10.0)
|
||||
return true
|
||||
}
|
||||
})
|
||||
left.setOnTouchListener { v, event ->
|
||||
left.setOnTouchListener { _, event ->
|
||||
leftDetector.onTouchEvent(event)
|
||||
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||
leftLongPressJob?.cancel()
|
||||
@@ -178,16 +269,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
||||
// 시스템 UI(상태바 등)를 다시 숨깁니다.
|
||||
hideSystemUI()
|
||||
|
||||
// 저장해둔 영상 해상도가 있다면 현재 바뀐 화면 크기에 맞춰 다시 정렬합니다.
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
adjustVideoAspectRatio(videoWidth, videoHeight)
|
||||
}
|
||||
if (videoWidth > 0 && videoHeight > 0) adjustVideoAspectRatio(videoWidth, videoHeight)
|
||||
}
|
||||
|
||||
// 💡 화면 채움 (Fill/Crop)
|
||||
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
|
||||
runOnUiThread {
|
||||
// 1. 현재 기기의 실제 가용 화면 크기를 가져옵니다.
|
||||
@@ -242,27 +328,238 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||
|
||||
private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "")
|
||||
|
||||
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
|
||||
val file = File(videoPath)
|
||||
if (file.exists()) {
|
||||
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
nativePlayer?.setDataSource(pfd.detachFd(), subtitlePath)
|
||||
nativePlayer?.play(Surface(st))
|
||||
var lastSubTitle : String = ""
|
||||
private fun startSubtitleSyncLoop() {
|
||||
subtitleSyncJob?.cancel()
|
||||
subtitleSyncJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
while (isActive) {
|
||||
if (isPlaying) {
|
||||
val currentSec = nativePlayer?.getCurrentPosition() ?: 0.0
|
||||
val currentSub = externalSubtitles.find { currentSec in it.startSec..it.endSec }
|
||||
|
||||
if (currentSub != null) {
|
||||
// 💡 번역본이 존재하면 [번역본] + [줄바꿈] + [원본] 형태로 보여주거나, 번역본만 보여줍니다.
|
||||
val displayText = if (currentSub.translatedText != null) {
|
||||
"${currentSub.translatedText}\n${cleanSubtitleText(currentSub.text)}"
|
||||
// (원본이 보기 싫다면 그냥 currentSub.translatedText 만 넣으셔도 됩니다!)
|
||||
} else {
|
||||
cleanSubtitleText(currentSub.text)
|
||||
}
|
||||
|
||||
if (displayText != lastSubTitle) {
|
||||
lastSubTitle = displayText
|
||||
subtitleView.text = displayText
|
||||
subtitleView.visibility = View.VISIBLE
|
||||
}
|
||||
} else {
|
||||
subtitleView.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun readTextWithEncoding(file: File): String {
|
||||
val bytes = file.readBytes()
|
||||
if (bytes.isEmpty()) return ""
|
||||
|
||||
// 1. 꼬리표(BOM) 100% 확정 검사
|
||||
if (bytes.size >= 3 && bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte()) {
|
||||
return String(bytes, 3, bytes.size - 3, Charsets.UTF_8)
|
||||
}
|
||||
if (bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) {
|
||||
return String(bytes, 2, bytes.size - 2, Charset.forName("UTF-16LE"))
|
||||
}
|
||||
if (bytes.size >= 2 && bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()) {
|
||||
return String(bytes, 2, bytes.size - 2, Charset.forName("UTF-16BE"))
|
||||
}
|
||||
|
||||
// 2. UTF-16 검사 (Null 바이트 비율)
|
||||
val nullCount = bytes.count { it == 0.toByte() }
|
||||
if (nullCount > bytes.size / 4) {
|
||||
return String(bytes, Charset.forName("UTF-16LE"))
|
||||
}
|
||||
|
||||
// 💡 3. UTF-8 강제 우선권 부여 (중국어 블랙홀 방지)
|
||||
try {
|
||||
val decoder = Charsets.UTF_8.newDecoder()
|
||||
decoder.onMalformedInput(CodingErrorAction.REPLACE)
|
||||
decoder.onUnmappableCharacter(CodingErrorAction.REPLACE)
|
||||
decoder.replaceWith("\uFFFD")
|
||||
|
||||
val utf8Text = decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
val utf8Errors = utf8Text.count { it == '\uFFFD' }
|
||||
|
||||
// 에러가 5% 미만이라면 사실상 UTF-8 파일이 부분 손상된 것으로 간주하고 확정!
|
||||
if (utf8Errors < utf8Text.length / 20) {
|
||||
return utf8Text
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
|
||||
// 💡 4. 한국어/일본어 전용 채점 (GB18030 같은 블랙홀은 리스트에서 배제)
|
||||
val charsets = listOf("CP949", "Shift_JIS", "EUC-JP")
|
||||
var bestText = String(bytes, Charsets.UTF_8) // 최후의 보루는 UTF-8
|
||||
var minErrors = Int.MAX_VALUE
|
||||
|
||||
for (charsetName in charsets) {
|
||||
try {
|
||||
val decoder = Charset.forName(charsetName).newDecoder()
|
||||
decoder.onMalformedInput(CodingErrorAction.REPLACE)
|
||||
decoder.onUnmappableCharacter(CodingErrorAction.REPLACE)
|
||||
decoder.replaceWith("\uFFFD")
|
||||
|
||||
val text = decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
val errorCount = text.count { it == '\uFFFD' }
|
||||
|
||||
// 에러가 가장 적은 인코딩 채택
|
||||
if (errorCount < minErrors) {
|
||||
minErrors = errorCount
|
||||
bestText = text
|
||||
}
|
||||
} catch (e: Exception) { }
|
||||
}
|
||||
|
||||
return bestText
|
||||
}
|
||||
|
||||
private fun parseSrt(file: File): List<SubtitleBlock> {
|
||||
val result = mutableListOf<SubtitleBlock>()
|
||||
if (!file.exists()) return result
|
||||
|
||||
try {
|
||||
// 💡 기존의 file.readText() 대신 우리가 만든 스마트 함수를 사용합니다.
|
||||
val content = readTextWithEncoding(file)
|
||||
|
||||
// --- 아래는 기존과 완전히 동일 ---
|
||||
val blocks = content.split("\n\n", "\r\n\r\n")
|
||||
|
||||
for (block in blocks) {
|
||||
val lines = block.lines().filter { it.isNotBlank() }
|
||||
if (lines.size >= 3) {
|
||||
val timeLine = lines[1]
|
||||
val text = lines.drop(2).joinToString("\n")
|
||||
|
||||
val times = timeLine.split(" --> ")
|
||||
if (times.size == 2) {
|
||||
result.add(SubtitleBlock(parseSrtTime(times[0]), parseSrtTime(times[1]), text))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PlayerActivity", "Subtitle parsing error", e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseSrtTime(timeStr: String): Double {
|
||||
val cleanStr = timeStr.trim().replace(",", ".")
|
||||
val parts = cleanStr.split(":")
|
||||
if (parts.size == 3) {
|
||||
val h = parts[0].toDoubleOrNull() ?: 0.0
|
||||
val m = parts[1].toDoubleOrNull() ?: 0.0
|
||||
val s = parts[2].toDoubleOrNull() ?: 0.0
|
||||
return (h * 3600) + (m * 60) + s
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
private fun hideSystemUI() {
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.setDecorFitsSystemWindows(false)
|
||||
window.insetsController?.let {
|
||||
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
|
||||
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {}
|
||||
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { nativePlayer?.stop(); return true }
|
||||
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
|
||||
|
||||
private fun detectLanguageAndTranslate() {
|
||||
if (externalSubtitles.isEmpty()) return
|
||||
|
||||
// 정확도를 위해 자막 앞부분의 텍스트를 적당히 뭉쳐서 샘플로 만듭니다.
|
||||
val sampleText = externalSubtitles.take(10).joinToString(" ") { it.text }
|
||||
|
||||
val languageIdentifier = LanguageIdentification.getClient()
|
||||
languageIdentifier.identifyLanguage(sampleText)
|
||||
.addOnSuccessListener { languageCode ->
|
||||
if (languageCode == "und") {
|
||||
Blog.LOGE("언어 감지 실패 (알 수 없는 언어)")
|
||||
} else {
|
||||
Blog.LOGE("💡 감지된 언어 코드: $languageCode")
|
||||
|
||||
// 만약 이미 한국어(ko)라면 번역할 필요가 없으므로 종료
|
||||
if (languageCode == "ko") return@addOnSuccessListener
|
||||
|
||||
// 감지된 언어 코드를 번역기가 이해할 수 있는 코드로 변환
|
||||
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
|
||||
if (sourceLang != null) {
|
||||
startTranslationProcess(sourceLang)
|
||||
} else {
|
||||
Blog.LOGE("번역을 지원하지 않는 언어입니다: $languageCode")
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Blog.LOGE("언어 감지 에러: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 2. 감지된 언어를 바탕으로 한국어로 번역하는 함수
|
||||
private fun startTranslationProcess(sourceLang: String) {
|
||||
val options = TranslatorOptions.Builder()
|
||||
.setSourceLanguage(sourceLang)
|
||||
.setTargetLanguage(TranslateLanguage.KOREAN) // 타겟은 무조건 한국어!
|
||||
.build()
|
||||
|
||||
val translator = Translation.getClient(options)
|
||||
|
||||
Toast.makeText(this, "번역 모델 확인 중...", Toast.LENGTH_SHORT).show()
|
||||
|
||||
// 해당 언어 모델이 폰에 없으면 다운로드 (최초 1회, 약 30MB)
|
||||
translator.downloadModelIfNeeded()
|
||||
.addOnSuccessListener {
|
||||
Toast.makeText(this, "자막 번역을 시작합니다!", Toast.LENGTH_SHORT).show()
|
||||
|
||||
// 💡 3. 수백~수천 줄의 자막을 백그라운드에서 한방에 번역 (UI 멈춤 방지)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
for (block in externalSubtitles) {
|
||||
try {
|
||||
// ML Kit의 번역은 원래 비동기(Task)지만, Tasks.await를 쓰면 동기적으로 쫙 뽑아낼 수 있습니다.
|
||||
val translated = com.google.android.gms.tasks.Tasks.await(translator.translate(block.text))
|
||||
block.translatedText = translated
|
||||
} catch (e: Exception) {
|
||||
block.translatedText = block.text // 에러나면 원본 유지
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@PlayerActivity, "자막 번역 완료!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
Toast.makeText(this, "번역 모델 다운로드 실패", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
nativePlayer?.destroy()
|
||||
leftLongPressJob?.cancel()
|
||||
subtitleSyncJob?.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user