This commit is contained in:
2026-04-20 17:10:15 +09:00
parent 5ded732345
commit 6813a6bdd7
18 changed files with 1204 additions and 173 deletions
@@ -7,9 +7,13 @@ import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
@@ -22,12 +26,16 @@ import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import bums.lunatic.launcher.R
import bums.lunatic.launcher.player.DocumentViewerActivity
import bums.lunatic.launcher.player.ImageViewerActivity
import bums.lunatic.launcher.player.NativePlayer
import bums.lunatic.launcher.player.PlayerActivity
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
@@ -78,6 +86,10 @@ enum class RenameMode(val label: String) {
SEQUENTIAL("순차적 직접 변경")
}
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp","srt", "smi", "ass", "vtt")
class CompletedFilesFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
@@ -96,9 +108,7 @@ class CompletedFilesFragment : Fragment() {
private var isSelectionMode = false
private val selectedFiles = mutableSetOf<File>()
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
private lateinit var backPressedCallback: OnBackPressedCallback
fun backPress() = backPressedCallback.handleOnBackPressed()
@@ -143,6 +153,10 @@ class CompletedFilesFragment : Fragment() {
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
}
private fun showVideoPreviewDialog(file: File) {
}
private fun setupRecyclerView(view: View) {
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
updateRecyclerViewLayoutManager()
@@ -174,6 +188,16 @@ class CompletedFilesFragment : Fragment() {
putExtra("VIDEO_PATH", file.absolutePath)
}
startActivity(intent)
} else if(extImages.contains(file.extension.lowercase())) {
val intent = Intent(requireContext(), ImageViewerActivity::class.java).apply {
putExtra("IMAGE_PATH", file.absolutePath)
}
startActivity(intent)
} else if(extDocs.contains(file.extension.lowercase())) {
val intent = Intent(requireContext(), DocumentViewerActivity::class.java).apply {
putExtra("FILE_PATH", file.absolutePath)
}
startActivity(intent)
} else {
openPrivateFile(requireContext(), file) // 이미지나 문서는 기존처럼
}
@@ -536,28 +560,33 @@ class CompletedFilesFragment : Fragment() {
// 💡 보호 대상 필터링
val protectedCount = selectedFiles.count { isProtectedFile(it) }
val filesToDelete = selectedFiles.filter { !isProtectedFile(it) }
// val filesToDelete : MutableList<File> = mutableListOf<File>().apply {
// addAll(selectedFiles.filter { !isProtectedFile(it) })
// }
if (protectedCount == 1) {
if (protectedCount > 0 && filesToDelete.isEmpty()) {
Toast.makeText(context, "Images 및 Videos 폴더 내 항목은 개별 삭제만 가능합니다.", Toast.LENGTH_LONG).show()
return@setOnClickListener
}
val message = if (protectedCount > 0) {
"보호된 항목 ${protectedCount}개를 제외하고 나머지 ${filesToDelete.size}개 항목을 삭제하시겠습니까?"
val message = if (protectedCount > 5) {
Toast.makeText(context, "보호된 폴더 내부는 5개 이하만 동시 삭제 가능함.", Toast.LENGTH_SHORT).show()
return@setOnClickListener
} else {
"선택한 ${filesToDelete.size}개 항목을 삭제하시겠습니까?"
"선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?"
}
android.app.AlertDialog.Builder(requireContext())
.setMessage(message)
.setPositiveButton("삭제") { _, _ ->
var delCount = 0
filesToDelete.forEach { file ->
if (file.isDirectory) {
if (file.deleteRecursively()) delCount++
} else if (file.delete()) {
delCount++
selectedFiles.forEach { file ->
if (isProtectedFolder(file)){
} else {
if (file.isDirectory) {
if (file.deleteRecursively()) delCount++
} else if (file.delete()) {
delCount++
}
}
}
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
@@ -815,64 +844,97 @@ class CompletedFilesFragment : Fragment() {
.show()
}
private fun getTargetFolderName(file: File): String {
val ext = file.extension.lowercase()
return when {
// 이미지 확장자 세트에 포함된 경우
extImages.contains(ext) -> "Images"
// 비디오 확장자 세트에 포함된 경우
extVideos.contains(ext) -> "Videos"
// 문서 확장자 세트에 포함된 경우
extDocs.contains(ext) -> "Documents"
// 그 외 모든 파일은 Etc 폴더로 분류
else -> "Etc"
}
}
private fun organizeRootFiles() {
CoroutineScope(Dispatchers.IO).launch {
// 1. 루트 폴더의 파일 목록 가져오
val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
// 1. 기초 설정
val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
val trashDir = File(rootDir, "trash").apply { if (!exists()) mkdirs() }
val videoTargetDir = File(rootDir, "Videos").apply { if (!exists()) mkdirs() }
var movedCount = 0
// 2. 널브러진 파일들을 확장자별 폴더로 이동
// [단계 1] 루트에 널브러진 파일 기본 정리
filesInRoot?.forEach { file ->
val ext = file.extension.lowercase()
val folderName = when {
extImages.contains(ext) -> "Images"
extVideos.contains(ext) -> "Videos"
extDocs.contains(ext) -> "Documents"
else -> "Etc"
}
val targetDir = File(rootDir, folderName)
if (!targetDir.exists()) targetDir.mkdirs()
if (file.renameTo(File(targetDir, file.name))) {
movedCount++
}
val folderName = getTargetFolderName(file)
val targetDir = File(rootDir, folderName).apply { if (!exists()) mkdirs() }
if (file.renameTo(File(targetDir, file.name))) movedCount++
}
// 💡 3. 빈 폴더 싹쓸이 기능 추가 (Bottom-Up 방식)
var deletedFolderCount = 0
// [단계 2] 특수 폴더 정리 및 잔여 파일 trash 이동
val subtitleExts = setOf("srt", "smi", "ass", "vtt", "txt")
val protectedDirs = setOf("Images", "Videos", "Documents", "Etc", "trash", "Youtube")
// walkBottomUp()을 사용하면 가장 깊은 하위 폴더부터 위로 올라오면서 검사합니다.
rootDir.walkBottomUp().forEach { dir ->
if (dir != rootDir && dir.isDirectory) {
// 💡 Images와 Videos 폴더는 비어있어도 삭제 대상에서 제외
if (dir.parentFile == rootDir && (dir.name == "Images" || dir.name == "Videos")) {
return@forEach
}
rootDir.listFiles()?.filter { it.isDirectory && !protectedDirs.contains(it.name) }?.forEach { folder ->
val innerFiles = folder.listFiles() ?: return@forEach
if (dir.listFiles()?.isEmpty() == true) {
if (dir.delete()) {
deletedFolderCount++
// 특수 조건(1GB 영상) 확인용 데이터
val videoFiles = innerFiles.filter { extVideos.contains(it.extension.lowercase()) }
val potentialSubtitles = innerFiles.filter { subtitleExts.contains(it.extension.lowercase()) }
val hasLargeVideo = videoFiles.any { it.length() >= 1024 * 1024 * 1024 }
val hasTinyText = potentialSubtitles.any { it.length() <= 1024 }
if (hasLargeVideo) {
// 조건 만족 시 영상+자막 이동
videoFiles.forEach { videoFile ->
if (videoFile.renameTo(File(videoTargetDir, videoFile.name))) {
movedCount++
val videoNameOnly = videoFile.nameWithoutExtension
potentialSubtitles.forEach { subFile ->
// if (subFile.nameWithoutExtension.startsWith(videoNameOnly)) {
if (subFile.renameTo(File(videoTargetDir, subFile.name))) movedCount++
// }
}
}
}
}
// [단계 3] 💡 위 로직에서 살아남은 나머지 잔여 파일들을 trash로 이동
// 이동 후 남은 파일을 다시 체크
folder.listFiles()?.filter { it.isFile }?.forEach { remainingFile ->
// 리네임 규칙: '원래폴더명_파일명'
val newName = "${folder.name}_${remainingFile.name}"
val destFile = File(trashDir, newName)
if (remainingFile.renameTo(destFile)) {
movedCount++
}
}
}
// 4. 메인 스레드에서 결과 메시지 출력 및 리스트 갱신
withContext(Dispatchers.Main) {
if (movedCount > 0 || deletedFolderCount > 0) {
val msg = buildString {
if (movedCount > 0) append("${movedCount}개의 파일을 폴더로 정리했습니다.\n")
if (deletedFolderCount > 0) append("${deletedFolderCount}개의 빈 폴더를 삭제했습니다.")
}.trim()
// [단계 4] 빈 폴더 싹쓸이 (Bottom-Up)
var deletedFolderCount = 0
rootDir.walkBottomUp().forEach { dir ->
if (dir != rootDir && dir.isDirectory) {
// 보호된 폴더는 비어있어도 삭제 안 함
if (dir.parentFile == rootDir && protectedDirs.contains(dir.name)) return@forEach
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
// 파일 이동이나 폴더 삭제가 하나라도 일어났다면 현재 화면 새로고침
loadFiles()
} else {
Toast.makeText(requireContext(), "정리할 파일이나 빈 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
if (dir.listFiles()?.isEmpty() == true) {
if (dir.delete()) deletedFolderCount++
}
}
}
withContext(Dispatchers.Main) {
val msg = "${movedCount}개 항목 정리(trash 포함) 및 ${deletedFolderCount}개 빈 폴더 삭제 완료"
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
loadFiles()
}
}
}
@@ -894,19 +956,22 @@ class CompletedFilesFragment : Fragment() {
if (!hidden) loadFiles()
}
private fun isProtectedFile(file: File): Boolean {
// 1. 루트의 Images, Videos 폴더 자체 보호
val protectedFolders = setOf("Images", "Videos","Youtube")
val protectedFolders = setOf("Images", "Videos","Youtube")
private fun isProtectedFolder(file: File): Boolean {
if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
return true
}
return false
}
// 2. Images나 Videos 폴더 안에 들어있는 파일/폴더들 보호
private fun isProtectedFile(file: File): Boolean {
if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
return true
}
val parent = file.parentFile
if (parent != null && parent.parentFile == rootDir && protectedFolders.contains(parent.name)) {
return true
}
return false
}
@@ -1092,7 +1157,40 @@ class CompletedFilesAdapter(
Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
}
}
var localPlayer: NativePlayer? = null
val textureView = itemView.findViewById<TextureView>(R.id.previewTextureView)
ivThumb?.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 💡 0.5초 뒤에도 누르고 있다면 재생 시작
v.postDelayed({
if (v.isPressed && extVideos.contains(file.extension.lowercase())) {
textureView.visibility = View.VISIBLE
localPlayer = NativePlayer().apply {
initialize()
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
setDataSource(pfd.detachFd(), -1)
onPreparedListener = {
seekTo(60.0) // 1분 지점
play(Surface(textureView.surfaceTexture))
}
prepareAsync()
}
}
}, 500)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// 💡 손을 떼면 즉시 뷰 숨기고 플레이어 파괴
v.isPressed = false
textureView.visibility = View.GONE
localPlayer?.stop()
localPlayer?.destroy()
localPlayer = null
}
}
true // 이벤트를 소비하여 롱클릭/클릭 충돌 방지 (필요 시 조정)
}
itemView.setOnClickListener { onItemClick(file) }
itemView.setOnLongClickListener {
if (file.name != "..") onItemLongClick(file)
@@ -28,6 +28,7 @@ import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContentProviderCompat.requireContext
import androidx.core.net.toUri
import androidx.core.view.isVisible
import bums.lunatic.launcher.BookmarkUploader
@@ -41,8 +42,10 @@ import bums.lunatic.launcher.home.tokiz.PortMessage
import bums.lunatic.launcher.model.Dotax
import bums.lunatic.launcher.model.DotaxArticles
import bums.lunatic.launcher.model.getRssData
import bums.lunatic.launcher.player.PlayerActivity
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CommonUtils
import bums.lunatic.launcher.utils.FileUtils
import bums.lunatic.launcher.utils.SimpleFingerGestures
import bums.lunatic.launcher.workers.TorrentService
import bums.lunatic.launcher.workers.WorkersDb
@@ -56,6 +59,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
@@ -330,7 +334,15 @@ open class GeckoWeb @JvmOverloads constructor(
}
override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
if (response.uri.contains(".apk")) return
val url = response.uri
// 💡 .srt 파일인 경우 플레이어 로직과 연결
if (url.endsWith(".srt", ignoreCase = true) || url.contains("download")) {
(context as? PlayerActivity)?.let { player ->
player.downloadSubtitle(url)
}
} else {
downloadFile(response.uri)
}
}
override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, element: GeckoSession.ContentDelegate.ContextElement) {
val pageUrl = element.baseUri ?: lastedUrl ?: return
@@ -612,8 +624,42 @@ open class GeckoWeb @JvmOverloads constructor(
null
}
}
private fun downloadSubtitleWithReferer(url: String, referer: String?) {
// 영상 파일명과 매칭하기 위해 PlayerActivity의 videoPath 정보 활용 필요
val filename = "downloaded_subtitle.srt"
val savePath = File(context.getExternalFilesDir(null), filename)
Thread {
try {
val request = Request.Builder()
.url(url)
.addHeader("Referer", referer ?: "")
.build()
val response = OkHttpClient().newCall(request).execute()
if (response.isSuccessful) {
response.body?.byteStream()?.use { input ->
FileOutputStream(savePath).use { output -> input.copyTo(output) }
}
post { context.toast("자막 다운로드 완료!") }
// 이후 PlayerActivity에 알림을 주어 자막 로드 실행
}
} catch (e: Exception) { e.printStackTrace() }
}.start()
}
private fun handlePortMessage(msg: PortMessage) {
when (msg.type) {
"SUBTITLE_LIST_RESULT" -> {
(context as? PlayerActivity)?.let { player ->
// PlayerActivity에 리스트 전달
player.runOnUiThread {
player.showDownSubtitleSelectionDialog(msg.subTitles)
}
}
}
"COOKIES_REPORT"-> {
// Blog.LOGE("${msg.value} -> ${msg.url}")
currentCookieString = msg.value ?: ""
@@ -675,7 +721,7 @@ open class GeckoWeb @JvmOverloads constructor(
fun sendSearchDo() = sendJsonMsg("searchDo")
fun saveMd(fast: Boolean? = false) = sendJsonMsg("saveContent", "fast" to fast)
private fun sendJsonMsg(type: String, vararg params: Pair<String, Any?>) {
fun sendJsonMsg(type: String, vararg params: Pair<String, Any?>) {
val json = JSONObject().put("type", type)
params.forEach { json.put(it.first, it.second) }
mPort?.postMessage(json)
@@ -105,6 +105,7 @@ class PortMessage {
var base64Data: String? = null
var value : String? = null
var url : String? = null
var subTitles : List<Map<String, String>> = listOf()
}
class BookContents {
var chapterTitle : String? = null
@@ -407,6 +407,15 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
fun setTypeface(tf: Typeface?) {
mainTextView?.setTypeface(tf)
sencondTextView?.setTypeface(tf)
if (text.isNotEmpty()) {
invalidatePagination()
}
}
private fun invalidatePagination() {
val currentText = text
text = "" // 트리거를 위해 비움
text = currentText // 다시 할당하여 paginateAsync 실행
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@@ -0,0 +1,290 @@
package bums.lunatic.launcher.player
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import bums.lunatic.launcher.home.tokiz.TouchArea
import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
import bums.lunatic.launcher.home.tokiz.view.PagedTextViewInterface
import bums.lunatic.launcher.model.Translation
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.FileUtils
import bums.lunatic.launcher.utils.FileUtils.charsets
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
import com.frostwire.jlibtorrent.swig.operation_t.file
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.TranslatorOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.charset.Charset
import java.text.SimpleDateFormat
class DocumentViewerActivity : AppCompatActivity() {
private lateinit var pagedLayout: PagedTextLayout
private lateinit var header: View
private val handler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideOverlay() }
private var currentFile: File? = null
private var currentRawBytes: ByteArray? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_document_viewer)
pagedLayout = findViewById(R.id.pagedTextLayout)
header = findViewById(R.id.layoutDocHeader)
pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
// 필요하다면 줄 간격이나 자간도 기본값으로 조정하여 가독성을 높입니다.
pagedLayout.setLineSpacing(20f) // 1.2배 정도의 줄간격
pagedLayout.setLetterSpacing(0f) // 기본 자간
val filePath = intent.getStringExtra("FILE_PATH") ?: return finish()
currentFile = File(filePath)
currentRawBytes = currentFile?.readBytes()
// 초기 자동 로드
pagedLayout.text = FileUtils.readTextWithEncoding(currentFile!!)
// 💡 인코딩 수동 선택 버튼 (예: 헤더의 특정 아이콘 클릭 시)
findViewById<View>(R.id.btnChangeEncoding).setOnClickListener {
showAdvancedEncodingDialog()
}
currentFile?.let { currentFile ->
val content = readTextWithEncoding(currentFile)
pagedLayout.text = content
// 2. 헤더 정보 표시
findViewById<TextView>(R.id.tvDocTitle).text = currentFile.name
val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm").format(currentFile.lastModified())
findViewById<TextView>(R.id.tvDocMeta).text = "수정일: $dateStr | 크기: ${currentFile.length() / 1024} KB"
}
showOverlay()
// 3. 제스처 인터페이스 설정
pagedLayout.mPagedTextViewInterface = object : PagedTextViewInterface {
override fun onTouch(touchArea: TouchArea) {
if (header.visibility == View.VISIBLE) hideOverlay() else showOverlay()
}
override fun onSwipeLeft(count: Int) { pagedLayout.doNext() }
override fun onSwipeRight(count: Int) { pagedLayout.doPrev() }
override fun onLongClick() { finish() } // 잠깐 확인용이므로 롱클릭 시 종료
override fun onTimeoverTouch() {}
override fun onSwipeDown(count: Int) {}
override fun onSwipeUp(count: Int) {}
}
}
private val fullEncodingList = mapOf(
"추천 (자동)" to listOf("UTF-8", "GB18030", "CP949", "BIG5", "Shift_JIS"),
"한국/중국/일본" to listOf("EUC-KR", "GBK", "EUC-JP", "ISO-2022-JP"),
"영어/서유럽" to listOf("ISO-8859-1", "Windows-1252", "ISO-8859-15"),
"유니코드/기타" to listOf("UTF-16LE", "UTF-16BE", "UTF-32")
)
private val flatEncodingList = fullEncodingList.values.flatten().distinct()
private fun showAdvancedEncodingDialog() {
var selectedIndex = 0
val items = flatEncodingList.toTypedArray()
android.app.AlertDialog.Builder(this)
.setTitle("인코딩 선택 (화면을 보며 확인하세요)")
.setSingleChoiceItems(items, -1) { _, which ->
selectedIndex = which
applyPreviewEncoding(items[which])
}
.setPositiveButton("확정 및 처리") { _, _ ->
// 💡 인코딩 확정 후 다음 액션 선택
showActionSelectionDialog(items[selectedIndex])
}
.setNeutralButton("다음 인코딩") { _, _ ->
selectedIndex = (selectedIndex + 1) % items.size
applyPreviewEncoding(items[selectedIndex])
// 다이얼로그 유지를 위해 재호출 로직 필요 시 추가
}
.setNegativeButton("취소", null)
.show()
}
private fun showActionSelectionDialog(charsetName: String) {
android.app.AlertDialog.Builder(this)
.setTitle("처리 방식 선택")
.setMessage("[$charsetName] 인코딩으로 확인되었습니다.\n어떤 방식으로 저장할까요?")
.setPositiveButton("번역 후 저장") { _, _ ->
// 💡 언어 감지 후 번역 진행
detectLanguageAndTranslate(charsetName)
}
.setNeutralButton("그냥 이대로 저장") { _, _ ->
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName)
}
.setNegativeButton("취소", null)
.show()
}
private fun detectLanguageAndTranslate(charsetName: String) {
val textSample = pagedLayout.text.toString().take(2000) // 💡 정확도를 위해 앞부분 2000자 샘플링
if (textSample.isBlank()) return
val languageIdentifier = LanguageIdentification.getClient()
languageIdentifier.identifyLanguage(textSample)
.addOnSuccessListener { languageCode ->
if (languageCode == "und") {
Toast.makeText(this, "언어를 판별할 수 없습니다. 기본값(중국어)으로 시도합니다.", Toast.LENGTH_SHORT).show()
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE)
} else if (languageCode == "ko") {
Toast.makeText(this, "이미 한국어 문서입니다. 번역 없이 저장합니다.", Toast.LENGTH_SHORT).show()
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName)
} else {
// 💡 감지된 언어 코드를 번역기 코드로 변환
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
if (sourceLang != null) {
translateAndSaveByParagraph(charsetName, sourceLang)
} else {
Toast.makeText(this, "지원하지 않는 언어($languageCode)입니다.", Toast.LENGTH_SHORT).show()
}
}
}
.addOnFailureListener {
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE) // 실패 시 기본값
}
}
private fun translateAndSaveByParagraph(charsetName: String, sourceLang: String) {
val originalFile = currentFile ?: return
// 💡 감지된 sourceLang 적용
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLang)
.setTargetLanguage(TranslateLanguage.KOREAN) // 타겟은 무조건 한국어!
.build()
val translator = com.google.mlkit.nl.translate.Translation.getClient(options)
val newFileName = "${originalFile.nameWithoutExtension}_translated_ko.txt"
val newFile = File(originalFile.parent, newFileName)
Toast.makeText(this, "[$sourceLang] 번역 작업 시작...", Toast.LENGTH_SHORT).show()
CoroutineScope(Dispatchers.IO).launch {
try {
Tasks.await(translator.downloadModelIfNeeded())
originalFile.inputStream().bufferedReader(Charset.forName(charsetName)).use { reader ->
newFile.outputStream().bufferedWriter(Charsets.UTF_8).use { writer ->
val paragraphBuilder = StringBuilder()
reader.forEachLine { line ->
if (line.isBlank()) {
if (paragraphBuilder.isNotEmpty()) {
// 💡 문단 단위 번역 (문맥 유지)
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
writer.newLine()
writer.newLine()
paragraphBuilder.clear()
}
} else {
paragraphBuilder.append(line).append(" ")
}
if (paragraphBuilder.length > 1000) { // 💡 너무 긴 문단 방지
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
paragraphBuilder.clear()
}
}
if (paragraphBuilder.isNotEmpty()) {
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
}
}
}
withContext(Dispatchers.Main) {
Toast.makeText(this@DocumentViewerActivity, "번역 완료!", Toast.LENGTH_SHORT).show()
currentFile = newFile
pagedLayout.text = FileUtils.readTextWithEncoding(newFile)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@DocumentViewerActivity, "번역 실패: ${e.message}", Toast.LENGTH_SHORT).show()
}
} finally {
translator.close()
}
}
}
var lastEncoded = ""
private fun applyPreviewEncoding(charset: String) {
val bytes = currentRawBytes ?: return
lastEncoded = charset
try {
val decoder = Charset.forName(charset).newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
.replaceWith("")
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
pagedLayout.text = text
} catch (e: Exception) {
Blog.LOGE("미리보기 실패: $charset")
}
}
private fun saveCurrentTextAsUtf8(validText: String, charsetName: String) {
val originalFile = currentFile ?: return
try {
// 1. 새 파일명 생성
val newFileName = "${originalFile.nameWithoutExtension}_${charsetName}.${originalFile.extension}"
val newFile = File(originalFile.parent, newFileName)
// 2. 스트림을 이용한 라인 단위 읽기 및 쓰기
// 원본을 선택한 인코딩(charsetName)으로 읽어서 UTF-8로 씁니다.
originalFile.inputStream().bufferedReader(Charset.forName(charsetName)).use { reader ->
newFile.outputStream().bufferedWriter(Charsets.UTF_8).use { writer ->
reader.forEachLine { line ->
writer.write(line)
writer.newLine()
}
}
}
Toast.makeText(this, "새 파일로 저장 완료:\n$newFileName", Toast.LENGTH_LONG).show()
// 3. 화면 갱신을 위해 새 파일 로드
currentFile = newFile
pagedLayout.text = FileUtils.readTextWithEncoding(newFile)
} catch (e: Exception) {
Toast.makeText(this, "저장 중 오류: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
private fun showOverlay() {
handler.removeCallbacks(hideRunnable)
header.visibility = View.VISIBLE
header.animate().alpha(1f).setDuration(300).start()
handler.postDelayed(hideRunnable, 3000)
}
private fun hideOverlay() {
header.animate().alpha(0f).setDuration(300).withEndAction {
header.visibility = View.GONE
}.start()
}
}
@@ -0,0 +1,112 @@
package bums.lunatic.launcher.player
import android.media.ExifInterface
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import com.bumptech.glide.Glide
import java.io.File
class ImageViewerActivity : AppCompatActivity() {
private lateinit var layoutMetaInfo: View
private val hideHandler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideMetaInfo() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_image_viewer)
val photoView = findViewById<com.github.chrisbanes.photoview.PhotoView>(R.id.photoView)
layoutMetaInfo = findViewById(R.id.layoutMetaInfo)
val btnToggleMeta = findViewById<ImageButton>(R.id.btnToggleMeta)
val imagePath = intent.getStringExtra("IMAGE_PATH") ?: return
// 1. 이미지 로드
Glide.with(this).load(File(imagePath)).into(photoView)
// 2. 메타데이터(Exif) 추출 및 표시
displayExifInfo(imagePath)
// 3. 초기 실행 시 3초간 보여주기
showMetaInfoWithDelay()
// 4. 버튼 클릭 리스너
btnToggleMeta.setOnClickListener {
if (layoutMetaInfo.visibility == View.VISIBLE) {
hideMetaInfo()
} else {
showMetaInfoWithDelay()
}
}
}
private fun getAllExifDetails(path: String): String {
val exif = androidx.exifinterface.media.ExifInterface(path)
val sb = StringBuilder()
// 1. 리플렉션을 사용하여 ExifInterface의 모든 TAG_ 필드를 가져옴
val fields = androidx.exifinterface.media.ExifInterface::class.java.fields
for (field in fields) {
if (field.name.startsWith("TAG_")) {
try {
val tagName = field.get(null) as String
val value = exif.getAttribute(tagName)
// 값이 있는 태그만 추가
if (!value.isNullOrBlank()) {
// 태그명에서 "TAG_" 접두어 제거하고 읽기 쉽게 변환 (예: TAG_MODEL -> Model)
val friendlyName = field.name.removePrefix("TAG_").replace("_", " ")
sb.append("**$friendlyName**: $value\n")
}
} catch (e: Exception) {
// 특정 태그 접근 실패 시 무시
}
}
}
// 2. 위치 정보는 별도 계산 (문자열보다 좌표값이 정확함)
val latLong = FloatArray(2)
if (exif.getLatLong(latLong)) {
sb.append("\n📍 **위치(GPS)**: ${latLong[0]}, ${latLong[1]}")
}
return if (sb.isEmpty()) "메타데이터 정보가 없습니다." else sb.toString()
}
private fun displayExifInfo(path: String) {
val exif = androidx.exifinterface.media.ExifInterface(path)
val info = StringBuilder().apply {
appendLine("모델: ${exif.getAttribute(ExifInterface.TAG_MODEL) ?: "알 수 없음"}\n")
appendLine("날짜: ${exif.getAttribute(ExifInterface.TAG_DATETIME) ?: "-"}\n")
appendLine("해상도: ${exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)}x${exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)}\n")
appendLine("조리개: f/${exif.getAttribute(ExifInterface.TAG_F_NUMBER) ?: "-"}")
appendLine(getAllExifDetails(path))
}.toString()
findViewById<TextView>(R.id.tvMetaTitle).text = File(path).name
findViewById<TextView>(R.id.tvMetaData).text = info
}
private fun showMetaInfoWithDelay() {
hideHandler.removeCallbacks(hideRunnable) // 기존 예약된 숨김 제거
layoutMetaInfo.visibility = View.VISIBLE
layoutMetaInfo.animate().alpha(1f).setDuration(300).start()
// 3초 후 숨김 예약
hideHandler.postDelayed(hideRunnable, 5000)
}
private fun hideMetaInfo() {
layoutMetaInfo.animate().alpha(0f).setDuration(300).withEndAction {
layoutMetaInfo.visibility = View.GONE
}.start()
}
}
@@ -14,15 +14,19 @@ import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContentProviderCompat.requireContext
import bums.lunatic.launcher.R
import bums.lunatic.launcher.home.GeckoWeb
import bums.lunatic.launcher.player.NativePlayer.SubtitleTrack
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
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.net.URLEncoder
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
@@ -36,6 +40,7 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
)
private lateinit var videoTextureView: TextureView
private lateinit var geckoWeb: GeckoWeb
private lateinit var subtitleView: TextView
private lateinit var btnRotate: ImageButton
private lateinit var btnHideVideo: ImageButton
@@ -80,6 +85,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
prepareEngine()
}
private fun prepareEngine() {
val videoFile = File(videoPath)
if (videoFile.exists()) {
@@ -141,7 +148,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
if (allSubtitleTracks.size > 1) {
showSubtitleSelectionDialog()
} else {
play()
showSubtitleSearchConfirmDialog()
}
}
}
@@ -158,6 +166,23 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
setupGestures()
}
private fun showSubtitleSearchConfirmDialog() {
AlertDialog.Builder(this)
.setTitle("자막 없음")
.setMessage("재생할 자막이 없습니다. 온라인에서 자막을 검색해볼까요?")
.setPositiveButton("검색") { _, _ ->
// 파일명을 쿼리로 전달하여 검색 실행
val videoFileName = File(videoPath).nameWithoutExtension
searchSubtitles(videoFileName)
}
.setNegativeButton("그냥 재생") { _, _ ->
play() // 자막 없이 재생 시작
}
.setCancelable(false)
.show()
}
private lateinit var seekBar: android.widget.SeekBar
private lateinit var tvTime: TextView
private var uiUpdateJob: Job? = null
@@ -167,6 +192,9 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
videoTextureView = TextureView(this).apply { surfaceTextureListener = this@PlayerActivity }
geckoWeb = GeckoWeb(this).apply {
visibility = View.GONE
}
subtitleView = TextView(this).apply {
setTextColor(Color.WHITE)
@@ -203,6 +231,7 @@ 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) })
val bottomControlLayout = android.widget.LinearLayout(this).apply {
@@ -245,7 +274,7 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
bottomControlLayout.addView(seekBar)
root.addView(bottomControlLayout, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM))
root.addView(geckoWeb, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
setContentView(root)
hideSystemUI()
@@ -314,17 +343,7 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
}
}
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])
play()
}
.show()
}
fun play() {
startUIUpdateLoop()
@@ -397,7 +416,32 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
hideSystemUI()
if (videoWidth > 0 && videoHeight > 0) adjustVideoAspectRatio(videoWidth, videoHeight)
}
private fun convertSubtitlesToSrt(subtitles: List<SubtitleBlock>): String {
val sb = StringBuilder()
subtitles.forEachIndexed { index, block ->
sb.append("${index + 1}\n") // 자막 번호
sb.append("${formatSrtTime(block.startSec)} --> ${formatSrtTime(block.endSec)}\n")
// 번역본이 있다면 번역본을, 없다면 원본을 저장 (혹은 둘 다 병기)
val textToSave = if (!block.translatedText.isNullOrEmpty()) {
"${block.translatedText}\n${block.text}" // 번역본 + 원본 병기
} else {
block.text
}
sb.append("$textToSave\n\n")
}
return sb.toString()
}
// 00:00:00,000 포맷으로 변환
private fun formatSrtTime(seconds: Double): String {
val h = (seconds / 3600).toInt()
val m = ((seconds % 3600) / 60).toInt()
val s = (seconds % 60).toInt()
val ms = ((seconds - seconds.toInt()) * 1000).toInt()
return String.format("%02d:%02d:%02d,%03d", h, m, s, ms)
}
// 💡 화면 채움 (Fill/Crop)
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
runOnUiThread {
@@ -443,14 +487,94 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
private fun findSubtitleFile(videoPath: String): String {
val file = File(videoPath)
val name = file.nameWithoutExtension
val extensions = listOf("srt", "ass", "smi")
// 💡 번역된 파일을 최우선으로 탐색
val extensions = listOf("translated.srt", "srt", "ass", "smi")
for (ext in extensions) {
val sub = File(file.parent, "$name.$ext")
// ext가 "translated.srt"인 경우 name_translated.srt가 됨
val subName = if (ext.contains(".")) "${name}_$ext" else "$name.$ext"
val sub = File(file.parent, subName)
if (sub.exists()) return sub.absolutePath
}
return ""
}
fun showDownSubtitleSelectionDialog(subList: List<Map<String, String>>) {
val items = subList.map { "[${it["lang"]}] ${it["title"]}" }.toTypedArray()
AlertDialog.Builder(this)
.setTitle("자막 선택")
.setItems(items) { _, which ->
val selected = subList[which]
val detailUrl = selected["downloadUrl"]
if (detailUrl != null) {
// 💡 상세 페이지로 이동하도록 GeckoWeb에 명령
geckoWeb.sendJsonMsg("GO_TO_SUBTITLE_DETAIL", "url" to detailUrl)
}
}
.show()
}
/**
* 지정된 폴더에 자막을 다운로드하고 플레이어에 적용합니다.
* @param url 자막 다운로드 주소
* @param fileName 저장할 파일명 (확장자 제외)
* @param targetDir 저장될 부모 폴더 (: 영상이 들어있는 폴더)
*/
fun downloadSubtitle(url: String) {
val videoFile = File(videoPath)
val targetDir = File(videoPath).parentFile
val newSubFile = File(targetDir, "${videoFile.nameWithoutExtension}.srt")
CoroutineScope(Dispatchers.IO).launch {
try {
val request = okhttp3.Request.Builder()
.url(url)
// 필요 시 Referer 추가 (GeckoWeb에서 넘겨받은 값 활용 가능)
.build()
val response = okhttp3.OkHttpClient().newCall(request).execute()
if (response.isSuccessful) {
response.body?.byteStream()?.use { input ->
newSubFile.outputStream().use { output ->
input.copyTo(output)
}
}
withContext(Dispatchers.Main) {
Toast.makeText(this@PlayerActivity, "자막 저장 완료: ${newSubFile.name}", Toast.LENGTH_SHORT).show()
// 1. 플레이어의 자막 경로 업데이트
subtitlePath = newSubFile.absolutePath
// 2. 앞서 만든 스마트 인코딩 및 번역 로직 트리거
// 이 안에서 인코딩 복구 -> 언어 감지 -> 번역 순으로 진행됩니다.
detectLanguageAndTranslate()
// 3. 자막 리스트 갱신 및 표시
loadAvailableSubtitles()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@PlayerActivity, "자막 다운로드 실패: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun searchSubtitles(query: String) {
val searchUrl = "https://www.subtitlecat.com/index.php?search=${URLEncoder.encode(query, "UTF-8")}"
geckoWeb.loadUrl(searchUrl)
geckoWeb.visibility = View.VISIBLE
}
private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "")
var lastSubTitle : String = ""
@@ -523,69 +647,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
}
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
@@ -709,8 +770,10 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
}
}
saveTranslatedSubtitle()
withContext(Dispatchers.Main) {
Toast.makeText(this@PlayerActivity, "자막 번역 완료!", Toast.LENGTH_SHORT).show()
Toast.makeText(this@PlayerActivity, "번역 완료 및 저장되었습니다.", Toast.LENGTH_SHORT).show()
}
}
}
@@ -719,6 +782,35 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
}
}
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])
play()
}
.show()
}
private fun saveTranslatedSubtitle() {
try {
val originalFile = File(subtitlePath)
val newFileName = "${originalFile.nameWithoutExtension}_translated.srt"
val newFile = File(originalFile.parent, newFileName)
val srtContent = convertSubtitlesToSrt(externalSubtitles)
newFile.writeText(srtContent, Charsets.UTF_8)
// 💡 중요: 다음 로딩 시 이 파일을 찾을 수 있도록 경로 업데이트 시나리오 고려
Blog.LOGD(log = "번역 자막 저장 완료: ${newFile.absolutePath}")
} catch (e: Exception) {
Log.e("Player", "번역 파일 저장 실패", e)
}
}
override fun onDestroy() {
super.onDestroy()
nativePlayer?.destroy()
@@ -0,0 +1,53 @@
package bums.lunatic.launcher.utils
import java.io.File
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
object FileUtils {
val charsets = listOf("UTF-8", "GB18030", "CP949", "BIG5", "Shift_JIS", "CP949", "Shift_JIS", "ISO-8859-1", "Windows-1252", "EUC-KR","GB2312","65001" )
fun readTextWithEncoding(file: File): String {
val bytes = file.readBytes()
if (bytes.isEmpty()) return ""
// 1. BOM 체크는 가장 정확하므로 유지
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)
}
// 2. 인코딩 후보 순서 (이미지 같은 자막은 GB18030이 강력한 후보입니다)
var bestText = ""
for (charsetName in charsets) {
try {
val decoder = java.nio.charset.Charset.forName(charsetName).newDecoder()
// 💡 핵심: REPLACE 대신 REPORT를 사용해 엄격하게 검사합니다.
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
// 인코딩이 조금이라도 어긋나면 여기서 Exception이 발생해 다음으로 넘어갑니다.
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
// 💡 추가 검증: 특수 기호(Ã, Â) 비율이 너무 높으면 중국어/한국어일 확률이 큼
if (isNaturalText(text)) {
return text
}
bestText = text
} catch (e: Exception) {
continue // 다음 인코딩으로
}
}
return if (bestText.isNotEmpty()) bestText else String(bytes, Charsets.UTF_8)
}
// 💡 텍스트가 깨진 외계어인지 확인하는 보조 함수
private fun isNaturalText(text: String): Boolean {
val garbageCount = text.count { it == 'Ã' || it == 'Â' || it == 'æ' }
// 전체 텍스트에서 저런 문자가 3% 이상 섞여있다면 깨진 것으로 간주
return garbageCount < (text.length / 30)
}
}
@@ -327,7 +327,7 @@ class MyWallpaperService : WallpaperService() {
}
val requiredSizeRatio = 0.5
val requiredSizeRatio = 0.4
private fun getVideoSize(file: File): Pair<Int, Int>? {
val retriever = android.media.MediaMetadataRetriever()
@@ -353,7 +353,7 @@ class MyWallpaperService : WallpaperService() {
val wm = WallpaperManager.getInstance(this@MyWallpaperService)
val requiredSize = Math.max(wm.desiredMinimumWidth, wm.desiredMinimumHeight) * requiredSizeRatio
val videoExtensions = listOf("mp4", "mkv", "avi", "mov", "webm")
val videoExtensions = listOf("mp4", "mkv", "avi", "mov", "webm", "gif")
val imageExtensions = listOf("jpg", "jpeg", "png", "bmp", "webp")
for (file in allFiles) {
@@ -375,7 +375,7 @@ class MyWallpaperService : WallpaperService() {
val size = getVideoSize(file)
width = size?.first ?: 0
height = size?.second ?: 0
isOk = (width >= requiredSize*0.65 && height >= requiredSize*0.65)
isOk = (width >= requiredSize * 0.65 && height >= requiredSize*0.65)
// Blog.LOGE("loadFiles videoExtensions requiredSize $requiredSize width $width height $height [$isOk]")
}
}
@@ -228,7 +228,7 @@ class TorrentService : Service() {
// 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬
if (isCharging) {
val maxSlots = if (isWifiConnected) 3 else 1
val maxSlots = if (isWifiConnected) 6 else 2
val sortedByPriority = torrentsWithMetadata.sortedBy { it.second }
sortedByPriority.forEachIndexed { index, pair ->