diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 158fa105..d70a96b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -168,7 +168,9 @@ dependencies { // implementation(project(":utils")) implementation( "com.github.bumptech.glide:glide:4.11.0") implementation ("com.github.bumptech.glide:okhttp3-integration:4.11.0") -// implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407") + implementation ("com.github.chrisbanes:PhotoView:2.3.0") + implementation ("androidx.exifinterface:exifinterface:1.3.6") + // https://mvnrepository.com/artifact/org.mozilla.geckoview/geckoview implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407") implementation("com.vladsch.flexmark:flexmark-all:0.64.8") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29a18ed2..1b8e1bc6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -85,7 +85,6 @@ android:networkSecurityConfig="@xml/network_security_config" android:hardwareAccelerated="true" android:usesCleartextTraffic="true" - android:screenOrientation="nosensor" android:windowSoftInputMode="adjustResize" android:requestLegacyExternalStorage="true" tools:ignore="HardcodedDebugMode"> @@ -126,6 +125,26 @@ android:exported="false"> + + + + + + { var type= response["type"]; switch (type) { + case "GO_TO_SUBTITLE_DETAIL": { + const detailUrl = response["url"]; + location.href = detailUrl; // 상세 페이지로 이동 + break; + } + case "SEARCH_SUBTITLE_CAT": { + const query = response["query"]; + const searchUrl = `https://www.subtitlecat.com/index.php?search=${encodeURIComponent(query)}`; + location.href = searchUrl; // 검색 페이지로 이동 + break; + } case "SCROLL_TOP": { window.scrollTo({ top: 0, @@ -638,6 +649,7 @@ function sendCookiesToNative() { url: location.href }); console.log("Cookies sent to native."); + scrollToEndAndExtract() } } catch (e) { // 예외 무시 @@ -708,6 +720,68 @@ document.addEventListener('DOMContentLoaded', function () { window.addEventListener('load', sendCookiesToNative); } }) +function extractSubtitleList() { + // 1. 현재 URL에서 'search' 파라미터(파일명) 추출 + const urlParams = new URLSearchParams(window.location.search); + const searchQuery = urlParams.get('search'); + + if (location.host.includes("subtitlecat.com") && searchQuery) { + // tbody 안의 모든 행(tr)을 가져옴 + const rows = Array.from(document.querySelectorAll('table.sub-table tbody tr')); + + const subList = rows.map(row => { + const cols = row.querySelectorAll('td'); + if (cols.length < 5) return null; + + // 첫 번째 td 안에 태그와 (translated from ...) 텍스트가 있음 + const linkEl = cols[0].querySelector('a'); + const fullText = cols[0].innerText; + + // 정규식을 사용하여 "translated from [언어]" 부분 추출 + const langMatch = fullText.match(/\(translated from (.*?)\)/); + const originalLang = langMatch ? langMatch[1] : "Unknown"; + + return { + title: linkEl ? linkEl.innerText.trim() : "No Title", + downloadUrl: linkEl ? linkEl.href : null, // 상세 페이지 URL + lang: originalLang, // 원문 언어 정보 + size: cols[2].querySelector('.sub-table__metric-value')?.innerText || "N/A", + downloads: cols[3].innerText.trim(), + originalFileName: decodeURIComponent(searchQuery) // 파일명 자동 매칭용 + }; + }).filter(item => item !== null && item.downloadUrl !== null); + + if (subList.length > 0) { + sendMessage({ + type: "SUBTITLE_LIST_RESULT", + list: subList + }); + } + } +} +function scrollToEndAndExtract() { + const scrollStep = 800; // 한 번에 스크롤할 양 + const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms) + + function step() { + const prevScrollY = window.scrollY; + window.scrollBy(0, scrollStep); + + // 💡 0.5초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작 + setTimeout(() => { + if (window.scrollY === prevScrollY || + (window.innerHeight + window.scrollY) >= document.body.scrollHeight) { + console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작"); + extractSubtitleList(); // 기존에 정의한 추출 함수 호출 + } else { + step(); // 아직 끝이 아니면 다음 스크롤 진행 + } + }, scrollDelay); + } + + step(); +} + const keywords = ["youtube", "mojeek"]; const url = location.href; diff --git a/app/src/main/assets/extensions/my_extension/sdsdsd.js b/app/src/main/assets/extensions/my_extension/sdsdsd.js index 303dd32b..7a339c50 100644 --- a/app/src/main/assets/extensions/my_extension/sdsdsd.js +++ b/app/src/main/assets/extensions/my_extension/sdsdsd.js @@ -1,29 +1,131 @@ +(function() { + console.log("Enhanced Scroll & Click Loop Started"); -window.addEventListener('load',()=>{ - const container = document.getElementById('container'); - container.addEventListener('focus',(e)=> { - window.onpopstate = function(event){ - try { - var historyUrl = new URL(document.referrer); - var referrerUrl = document.referrer; - console.log(history.state); - if(historyUrl.host != location.host || referrerUrl.includes('pntPointBankBill')){ - // 현재 페이지 상태를 히스토리에 추가 - history.pushState(null, null, location.href); - // 뒤로가기 버튼 누를 때 호출되는 이벤트 처리 - // 홈으로 이동 - location.href = "/nhaob/main/main.nh"; - } else { - history.pushState("BACK", null, "/nhaob/main/main.nh"); - console.log("did pushState"); + const CLICK_INTERVAL = 1000; + const SCROLL_STEP = 300; + + const selectors = [ + 'a', 'button', 'a.btn', 'input[type="button"]', + 'input[type="submit"]', '[role="button"]' + ]; + + // --- 설정 구역 --- + const config = { + // 1. 특정 페이지에서 순서대로 클릭해야 하는 경우 + sequences: { + "www.nhmembers.co.kr/nhaob": [".btn-direct-menu"], + "example.com/login": [".userid-input", ".password-input", "#login-btn"], + "mysite.com/survey": ["input[value='yes']", "button.next-step"] + }, + // 2. 특정 페이지에서 절대 클릭하면 안 되는 요소 (예: 로그아웃, 삭제 버튼) + blacklist: { + "any": ["logout", "delete", "remove", "exit","header__prev main_header"], // 모든 페이지 공통 키워드 + "runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"] // 특정 페이지 전용 + } + }; + + let sequenceStep = 0; + let lastUrl = ""; + + const mainLoop = setInterval(() => { + const currentUrl = window.location.href; + + // 페이지가 바뀌면 순서(Step) 초기화 + if (currentUrl !== lastUrl) { + sequenceStep = 0; + lastUrl = currentUrl; + } + + // --- 로직 1: 특정 주소에서의 순차 클릭 처리 --- + const activeSequenceKey = Object.keys(config.sequences).find(url => currentUrl.includes(url)); + + if (activeSequenceKey) { + const steps = config.sequences[activeSequenceKey]; + if (sequenceStep < steps.length) { + const targetSelector = steps[sequenceStep]; + const target = document.querySelector(targetSelector); + + if (target && isVisible(target)) { + console.log(`[Sequence] Clicking step ` + sequenceStep + ' : ', targetSelector); + target.click(); + sequenceStep++; // 다음 단계로 + return; // 순차 클릭을 했을 경우 랜덤 클릭 건너뜀 } - } catch (e) { - console.log(e); } } - }) - container.focus(); -}) -window.focus() -document.body.focus() + // --- 로직 2: 일반 랜덤 클릭 (예외 처리 포함) --- + window.scrollBy(0, SCROLL_STEP); + + const elements = document.querySelectorAll(selectors.join(',')); + const visibleElements = Array.from(elements).filter(el => { + if (!isVisible(el)) return false; + + // 현재 페이지에 적용할 블랙리스트 단어들 모으기 + const pageBlacklist = config.blacklist["any"].concat( + Object.entries(config.blacklist) + .filter(([url]) => currentUrl.includes(url)) + .flatMap(([_, tags]) => tags) + ); + + // 검사할 대상 문자열들을 배열로 만듦 + const targetsToScan = [ + el.innerText, // 버튼 위 텍스트 + el.id, // ID + el.className.toString(), // 클래스명 + el.getAttribute('alt'), // 이미지 대체 텍스트 (중요!) + el.getAttribute('title'), // 마우스 올리면 나오는 텍스트 + el.getAttribute('aria-label') // 웹 접근성용 라벨 + ].map(val => (val || "").toLowerCase()); // 소문자로 통일해서 비교 + + // 블랙리스트 단어가 위 항목 중 하나라도 포함되어 있는지 확인 + const isBlacklisted = pageBlacklist.some(term => + targetsToScan.some(content => content.includes(term.toLowerCase())) + ); + + return !isBlacklisted; + }); + + if (visibleElements.length > 0) { + const randomIndex = Math.floor(Math.random() * visibleElements.length); + const target = visibleElements[randomIndex]; + console.log('Target Clicked:', target.innerText || target.tagName); + try { target.click(); } catch (e) { console.error(e); } + } + + if ((window.innerHeight + window.pageYOffset) >= document.documentElement.scrollHeight) { + window.scrollTo(0, 0); + } + + }, CLICK_INTERVAL); + + // 가시성 확인 함수 + function isVisible(el) { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && + rect.top >= 0 && rect.top <= window.innerHeight && + window.getComputedStyle(el).visibility !== 'hidden' && + window.getComputedStyle(el).display !== 'none'; + } + + window.autoCrawlerLoop = mainLoop; +})(); + + +window.addEventListener('error', function(event) { + // 1. 일반적인 JS 문법/런타임 에러 + if (event.message) { + const errorLog = { + type: 'JS_ERROR, + message: event.message, + source: event.filename, + line: event.lineno + }; + console.warn(`Error: ` + event.message); + } + // 2. 리소스 로드 에러 (이미지, JS 파일이 없을 때) + else { + const target = event.target || event.srcElement; + console.warn('Resource Load Error:', target.src || target.href); + } +}, true); diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt index 275cf203..bff2af27 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt @@ -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() - 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 = mutableListOf().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(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) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt index 949c6c8d..4899cc3e 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -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) { + fun sendJsonMsg(type: String, vararg params: Pair) { val json = JSONObject().put("type", type) params.forEach { json.put(it.first, it.second) } mPort?.postMessage(json) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt index 23bb0a07..bc6d475e 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt @@ -105,6 +105,7 @@ class PortMessage { var base64Data: String? = null var value : String? = null var url : String? = null + var subTitles : List> = listOf() } class BookContents { var chapterTitle : String? = null diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/view/PagedTextLayout.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/view/PagedTextLayout.kt index 9f0e282a..7fed96c3 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/view/PagedTextLayout.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/view/PagedTextLayout.kt @@ -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) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/DocumentViewerActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/DocumentViewerActivity.kt new file mode 100644 index 00000000..798609b0 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/DocumentViewerActivity.kt @@ -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(R.id.btnChangeEncoding).setOnClickListener { + showAdvancedEncodingDialog() + } + currentFile?.let { currentFile -> + val content = readTextWithEncoding(currentFile) + pagedLayout.text = content + + // 2. 헤더 정보 표시 + findViewById(R.id.tvDocTitle).text = currentFile.name + val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm").format(currentFile.lastModified()) + findViewById(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() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/ImageViewerActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/ImageViewerActivity.kt new file mode 100644 index 00000000..cedff7e0 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/ImageViewerActivity.kt @@ -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(R.id.photoView) + layoutMetaInfo = findViewById(R.id.layoutMetaInfo) + val btnToggleMeta = findViewById(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(R.id.tvMetaTitle).text = File(path).name + findViewById(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() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt index 47e37641..b4cc5a83 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt @@ -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): 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>) { + 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 { val result = mutableListOf() 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() diff --git a/app/src/main/kotlin/bums/lunatic/launcher/utils/FileUtils.kt b/app/src/main/kotlin/bums/lunatic/launcher/utils/FileUtils.kt new file mode 100644 index 00000000..72961035 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/utils/FileUtils.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt b/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt index 26a2d674..3e95e56e 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt @@ -327,7 +327,7 @@ class MyWallpaperService : WallpaperService() { } - val requiredSizeRatio = 0.5 + val requiredSizeRatio = 0.4 private fun getVideoSize(file: File): Pair? { 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]") } } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt index e41d145b..d04a94cd 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt @@ -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 -> diff --git a/app/src/main/res/layout/activity_document_viewer.xml b/app/src/main/res/layout/activity_document_viewer.xml new file mode 100644 index 00000000..acf2fb08 --- /dev/null +++ b/app/src/main/res/layout/activity_document_viewer.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_image_viewer.xml b/app/src/main/res/layout/activity_image_viewer.xml new file mode 100644 index 00000000..45da38b1 --- /dev/null +++ b/app/src/main/res/layout/activity_image_viewer.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_file_grid.xml b/app/src/main/res/layout/item_file_grid.xml index 75d6ce4a..950bd37d 100644 --- a/app/src/main/res/layout/item_file_grid.xml +++ b/app/src/main/res/layout/item_file_grid.xml @@ -5,6 +5,21 @@ android:orientation="vertical" android:padding="4dp" android:background="?attr/selectableItemBackground"> - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_file_list_thumb.xml b/app/src/main/res/layout/item_file_list_thumb.xml index d60b6f8a..77510732 100644 --- a/app/src/main/res/layout/item_file_list_thumb.xml +++ b/app/src/main/res/layout/item_file_list_thumb.xml @@ -6,7 +6,23 @@ android:padding="8dp" android:background="?attr/selectableItemBackground" android:gravity="center_vertical"> - + + + + + + +