This commit is contained in:
lunaticbum 2026-04-20 17:10:15 +09:00
parent 5ded732345
commit 6813a6bdd7
18 changed files with 1204 additions and 173 deletions

View File

@ -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")

View File

@ -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">
</activity>
<activity
android:name=".player.DocumentViewerActivity"
android:theme="@style/Theme.Player"
android:launchMode="singleInstance"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:screenOrientation="portrait"
android:hardwareAccelerated="true"
android:exported="false">
</activity>
<activity
android:name=".player.ImageViewerActivity"
android:theme="@style/Theme.Player"
android:launchMode="singleInstance"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:screenOrientation="sensor"
android:hardwareAccelerated="true"
android:exported="false">
</activity>
<activity
android:name=".LauncherActivity"

View File

@ -55,6 +55,17 @@ port.onMessage.addListener(response => {
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 안에 <a> 태그와 (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;

View File

@ -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);

View File

@ -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)

View 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)

View File

@ -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

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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]")
}
}

View File

@ -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 ->

View File

@ -0,0 +1,48 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A1A">
<bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
android:id="@+id/pagedTextLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/layoutDocHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#CC000000"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvDocTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/btnChangeEncoding"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:src="@android:drawable/ic_menu_manage"
android:background="?attr/selectableItemBackgroundBorderless" />
</RelativeLayout>
<TextView
android:id="@+id/tvDocMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,54 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/photoView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ScrollView
android:id="@+id/layoutMetaInfo"
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_gravity="bottom"
android:background="#AA000000"
android:padding="16dp"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tvMetaTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFBB00"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvMetaData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:textColor="#FFFFFF"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
<ImageButton
android:id="@+id/btnToggleMeta"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="top|end"
android:layout_margin="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_info_details"
android:contentDescription="정보 보기" />
</FrameLayout>

View File

@ -5,6 +5,21 @@
android:orientation="vertical"
android:padding="4dp"
android:background="?attr/selectableItemBackground">
<ImageView android:id="@+id/ivThumb" android:layout_width="match_parent" android:layout_height="100dp" android:scaleType="centerCrop" android:background="#E0E0E0"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="100dp">
<ImageView
android:id="@+id/ivThumb"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<TextureView
android:id="@+id/previewTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="end" android:gravity="center" android:layout_marginTop="4dp"/>
</LinearLayout>

View File

@ -6,7 +6,23 @@
android:padding="8dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<ImageView android:id="@+id/ivThumb" android:layout_width="60dp" android:layout_height="60dp" android:scaleType="centerCrop" android:background="#E0E0E0"/>
<FrameLayout
android:layout_width="60dp"
android:layout_height="60dp">
<ImageView
android:id="@+id/ivThumb"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<TextureView
android:id="@+id/previewTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:layout_marginStart="12dp">
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="middle"/>
<TextView android:id="@+id/tvFileSize" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/darker_gray" android:layout_marginTop="4dp"/>