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