/...
This commit is contained in:
parent
5ded732345
commit
6813a6bdd7
@ -168,7 +168,9 @@ dependencies {
|
|||||||
// implementation(project(":utils"))
|
// implementation(project(":utils"))
|
||||||
implementation( "com.github.bumptech.glide:glide:4.11.0")
|
implementation( "com.github.bumptech.glide:glide:4.11.0")
|
||||||
implementation ("com.github.bumptech.glide:okhttp3-integration: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
|
// https://mvnrepository.com/artifact/org.mozilla.geckoview/geckoview
|
||||||
implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407")
|
implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407")
|
||||||
implementation("com.vladsch.flexmark:flexmark-all:0.64.8")
|
implementation("com.vladsch.flexmark:flexmark-all:0.64.8")
|
||||||
|
|||||||
@ -85,7 +85,6 @@
|
|||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:screenOrientation="nosensor"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
tools:ignore="HardcodedDebugMode">
|
tools:ignore="HardcodedDebugMode">
|
||||||
@ -126,6 +125,26 @@
|
|||||||
android:exported="false">
|
android:exported="false">
|
||||||
</activity>
|
</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
|
<activity
|
||||||
android:name=".LauncherActivity"
|
android:name=".LauncherActivity"
|
||||||
|
|||||||
@ -55,6 +55,17 @@ port.onMessage.addListener(response => {
|
|||||||
var type= response["type"];
|
var type= response["type"];
|
||||||
|
|
||||||
switch (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": {
|
case "SCROLL_TOP": {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
@ -638,6 +649,7 @@ function sendCookiesToNative() {
|
|||||||
url: location.href
|
url: location.href
|
||||||
});
|
});
|
||||||
console.log("Cookies sent to native.");
|
console.log("Cookies sent to native.");
|
||||||
|
scrollToEndAndExtract()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 예외 무시
|
// 예외 무시
|
||||||
@ -708,6 +720,68 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
window.addEventListener('load', sendCookiesToNative);
|
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 keywords = ["youtube", "mojeek"];
|
||||||
const url = location.href;
|
const url = location.href;
|
||||||
|
|||||||
@ -1,29 +1,131 @@
|
|||||||
|
(function() {
|
||||||
|
console.log("Enhanced Scroll & Click Loop Started");
|
||||||
|
|
||||||
window.addEventListener('load',()=>{
|
const CLICK_INTERVAL = 1000;
|
||||||
const container = document.getElementById('container');
|
const SCROLL_STEP = 300;
|
||||||
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");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
container.focus();
|
|
||||||
})
|
|
||||||
|
|
||||||
window.focus()
|
const selectors = [
|
||||||
document.body.focus()
|
'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; // 순차 클릭을 했을 경우 랜덤 클릭 건너뜀
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 로직 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.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.TextureView
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
@ -22,12 +26,16 @@ import android.widget.Spinner
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import bums.lunatic.launcher.R
|
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 bums.lunatic.launcher.player.PlayerActivity
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -78,6 +86,10 @@ enum class RenameMode(val label: String) {
|
|||||||
SEQUENTIAL("순차적 직접 변경")
|
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() {
|
class CompletedFilesFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var recyclerView: RecyclerView
|
private lateinit var recyclerView: RecyclerView
|
||||||
@ -96,9 +108,7 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
private var isSelectionMode = false
|
private var isSelectionMode = false
|
||||||
private val selectedFiles = mutableSetOf<File>()
|
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
|
private lateinit var backPressedCallback: OnBackPressedCallback
|
||||||
fun backPress() = backPressedCallback.handleOnBackPressed()
|
fun backPress() = backPressedCallback.handleOnBackPressed()
|
||||||
@ -143,6 +153,10 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showVideoPreviewDialog(file: File) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupRecyclerView(view: View) {
|
private fun setupRecyclerView(view: View) {
|
||||||
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
|
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
|
||||||
updateRecyclerViewLayoutManager()
|
updateRecyclerViewLayoutManager()
|
||||||
@ -174,6 +188,16 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
putExtra("VIDEO_PATH", file.absolutePath)
|
putExtra("VIDEO_PATH", file.absolutePath)
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
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 {
|
} else {
|
||||||
openPrivateFile(requireContext(), file) // 이미지나 문서는 기존처럼
|
openPrivateFile(requireContext(), file) // 이미지나 문서는 기존처럼
|
||||||
}
|
}
|
||||||
@ -536,30 +560,35 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
|
|
||||||
// 💡 보호 대상 필터링
|
// 💡 보호 대상 필터링
|
||||||
val protectedCount = selectedFiles.count { isProtectedFile(it) }
|
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) {
|
val message = if (protectedCount > 5) {
|
||||||
"보호된 항목 ${protectedCount}개를 제외하고 나머지 ${filesToDelete.size}개 항목을 삭제하시겠습니까?"
|
Toast.makeText(context, "보호된 폴더 내부는 5개 이하만 동시 삭제 가능함.", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
} else {
|
} else {
|
||||||
"선택한 ${filesToDelete.size}개 항목을 삭제하시겠습니까?"
|
"선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?"
|
||||||
}
|
}
|
||||||
|
|
||||||
android.app.AlertDialog.Builder(requireContext())
|
android.app.AlertDialog.Builder(requireContext())
|
||||||
.setMessage(message)
|
.setMessage(message)
|
||||||
.setPositiveButton("삭제") { _, _ ->
|
.setPositiveButton("삭제") { _, _ ->
|
||||||
var delCount = 0
|
var delCount = 0
|
||||||
filesToDelete.forEach { file ->
|
selectedFiles.forEach { file ->
|
||||||
|
if (isProtectedFolder(file)){
|
||||||
|
|
||||||
|
} else {
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
if (file.deleteRecursively()) delCount++
|
if (file.deleteRecursively()) delCount++
|
||||||
} else if (file.delete()) {
|
} else if (file.delete()) {
|
||||||
delCount++
|
delCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
|
||||||
toggleSelectionMode(false)
|
toggleSelectionMode(false)
|
||||||
loadFiles()
|
loadFiles()
|
||||||
@ -815,63 +844,96 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun organizeRootFiles() {
|
private fun getTargetFolderName(file: File): String {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
// 1. 루트 폴더의 파일 목록 가져오기
|
|
||||||
val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
|
|
||||||
var movedCount = 0
|
|
||||||
|
|
||||||
// 2. 널브러진 파일들을 확장자별 폴더로 이동
|
|
||||||
filesInRoot?.forEach { file ->
|
|
||||||
val ext = file.extension.lowercase()
|
val ext = file.extension.lowercase()
|
||||||
val folderName = when {
|
return when {
|
||||||
|
// 이미지 확장자 세트에 포함된 경우
|
||||||
extImages.contains(ext) -> "Images"
|
extImages.contains(ext) -> "Images"
|
||||||
|
|
||||||
|
// 비디오 확장자 세트에 포함된 경우
|
||||||
extVideos.contains(ext) -> "Videos"
|
extVideos.contains(ext) -> "Videos"
|
||||||
|
|
||||||
|
// 문서 확장자 세트에 포함된 경우
|
||||||
extDocs.contains(ext) -> "Documents"
|
extDocs.contains(ext) -> "Documents"
|
||||||
|
|
||||||
|
// 그 외 모든 파일은 Etc 폴더로 분류
|
||||||
else -> "Etc"
|
else -> "Etc"
|
||||||
}
|
}
|
||||||
val targetDir = File(rootDir, folderName)
|
}
|
||||||
if (!targetDir.exists()) targetDir.mkdirs()
|
|
||||||
|
|
||||||
if (file.renameTo(File(targetDir, file.name))) {
|
private fun organizeRootFiles() {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// [단계 1] 루트에 널브러진 파일 기본 정리
|
||||||
|
filesInRoot?.forEach { file ->
|
||||||
|
val folderName = getTargetFolderName(file)
|
||||||
|
val targetDir = File(rootDir, folderName).apply { if (!exists()) mkdirs() }
|
||||||
|
if (file.renameTo(File(targetDir, file.name))) movedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// [단계 2] 특수 폴더 정리 및 잔여 파일 trash 이동
|
||||||
|
val subtitleExts = setOf("srt", "smi", "ass", "vtt", "txt")
|
||||||
|
val protectedDirs = setOf("Images", "Videos", "Documents", "Etc", "trash", "Youtube")
|
||||||
|
|
||||||
|
rootDir.listFiles()?.filter { it.isDirectory && !protectedDirs.contains(it.name) }?.forEach { folder ->
|
||||||
|
val innerFiles = folder.listFiles() ?: return@forEach
|
||||||
|
|
||||||
|
// 특수 조건(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++
|
movedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 💡 3. 빈 폴더 싹쓸이 기능 추가 (Bottom-Up 방식)
|
// [단계 4] 빈 폴더 싹쓸이 (Bottom-Up)
|
||||||
var deletedFolderCount = 0
|
var deletedFolderCount = 0
|
||||||
|
|
||||||
// walkBottomUp()을 사용하면 가장 깊은 하위 폴더부터 위로 올라오면서 검사합니다.
|
|
||||||
rootDir.walkBottomUp().forEach { dir ->
|
rootDir.walkBottomUp().forEach { dir ->
|
||||||
if (dir != rootDir && dir.isDirectory) {
|
if (dir != rootDir && dir.isDirectory) {
|
||||||
// 💡 Images와 Videos 폴더는 비어있어도 삭제 대상에서 제외
|
// 보호된 폴더는 비어있어도 삭제 안 함
|
||||||
if (dir.parentFile == rootDir && (dir.name == "Images" || dir.name == "Videos")) {
|
if (dir.parentFile == rootDir && protectedDirs.contains(dir.name)) return@forEach
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dir.listFiles()?.isEmpty() == true) {
|
if (dir.listFiles()?.isEmpty() == true) {
|
||||||
if (dir.delete()) {
|
if (dir.delete()) deletedFolderCount++
|
||||||
deletedFolderCount++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 메인 스레드에서 결과 메시지 출력 및 리스트 갱신
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (movedCount > 0 || deletedFolderCount > 0) {
|
val msg = "${movedCount}개 항목 정리(trash 포함) 및 ${deletedFolderCount}개 빈 폴더 삭제 완료"
|
||||||
val msg = buildString {
|
|
||||||
if (movedCount > 0) append("${movedCount}개의 파일을 폴더로 정리했습니다.\n")
|
|
||||||
if (deletedFolderCount > 0) append("${deletedFolderCount}개의 빈 폴더를 삭제했습니다.")
|
|
||||||
}.trim()
|
|
||||||
|
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
// 파일 이동이나 폴더 삭제가 하나라도 일어났다면 현재 화면 새로고침
|
|
||||||
loadFiles()
|
loadFiles()
|
||||||
} else {
|
|
||||||
Toast.makeText(requireContext(), "정리할 파일이나 빈 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -894,19 +956,22 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
if (!hidden) loadFiles()
|
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)) {
|
if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
|
||||||
return true
|
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
|
val parent = file.parentFile
|
||||||
if (parent != null && parent.parentFile == rootDir && protectedFolders.contains(parent.name)) {
|
if (parent != null && parent.parentFile == rootDir && protectedFolders.contains(parent.name)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1092,7 +1157,40 @@ class CompletedFilesAdapter(
|
|||||||
Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
|
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.setOnClickListener { onItemClick(file) }
|
||||||
itemView.setOnLongClickListener {
|
itemView.setOnLongClickListener {
|
||||||
if (file.name != "..") onItemLongClick(file)
|
if (file.name != "..") onItemLongClick(file)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import android.widget.RadioGroup
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContentProviderCompat.requireContext
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import bums.lunatic.launcher.BookmarkUploader
|
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.Dotax
|
||||||
import bums.lunatic.launcher.model.DotaxArticles
|
import bums.lunatic.launcher.model.DotaxArticles
|
||||||
import bums.lunatic.launcher.model.getRssData
|
import bums.lunatic.launcher.model.getRssData
|
||||||
|
import bums.lunatic.launcher.player.PlayerActivity
|
||||||
import bums.lunatic.launcher.utils.Blog
|
import bums.lunatic.launcher.utils.Blog
|
||||||
import bums.lunatic.launcher.utils.CommonUtils
|
import bums.lunatic.launcher.utils.CommonUtils
|
||||||
|
import bums.lunatic.launcher.utils.FileUtils
|
||||||
import bums.lunatic.launcher.utils.SimpleFingerGestures
|
import bums.lunatic.launcher.utils.SimpleFingerGestures
|
||||||
import bums.lunatic.launcher.workers.TorrentService
|
import bums.lunatic.launcher.workers.TorrentService
|
||||||
import bums.lunatic.launcher.workers.WorkersDb
|
import bums.lunatic.launcher.workers.WorkersDb
|
||||||
@ -56,6 +59,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@ -330,8 +334,16 @@ open class GeckoWeb @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
|
override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
|
||||||
if (response.uri.contains(".apk")) return
|
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)
|
downloadFile(response.uri)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, element: GeckoSession.ContentDelegate.ContextElement) {
|
override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, element: GeckoSession.ContentDelegate.ContextElement) {
|
||||||
val pageUrl = element.baseUri ?: lastedUrl ?: return
|
val pageUrl = element.baseUri ?: lastedUrl ?: return
|
||||||
val mediaUrl = element.srcUri ?: return
|
val mediaUrl = element.srcUri ?: return
|
||||||
@ -612,8 +624,42 @@ open class GeckoWeb @JvmOverloads constructor(
|
|||||||
null
|
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) {
|
private fun handlePortMessage(msg: PortMessage) {
|
||||||
when (msg.type) {
|
when (msg.type) {
|
||||||
|
"SUBTITLE_LIST_RESULT" -> {
|
||||||
|
(context as? PlayerActivity)?.let { player ->
|
||||||
|
// PlayerActivity에 리스트 전달
|
||||||
|
player.runOnUiThread {
|
||||||
|
player.showDownSubtitleSelectionDialog(msg.subTitles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"COOKIES_REPORT"-> {
|
"COOKIES_REPORT"-> {
|
||||||
// Blog.LOGE("${msg.value} -> ${msg.url}")
|
// Blog.LOGE("${msg.value} -> ${msg.url}")
|
||||||
currentCookieString = msg.value ?: ""
|
currentCookieString = msg.value ?: ""
|
||||||
@ -675,7 +721,7 @@ open class GeckoWeb @JvmOverloads constructor(
|
|||||||
fun sendSearchDo() = sendJsonMsg("searchDo")
|
fun sendSearchDo() = sendJsonMsg("searchDo")
|
||||||
fun saveMd(fast: Boolean? = false) = sendJsonMsg("saveContent", "fast" to fast)
|
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)
|
val json = JSONObject().put("type", type)
|
||||||
params.forEach { json.put(it.first, it.second) }
|
params.forEach { json.put(it.first, it.second) }
|
||||||
mPort?.postMessage(json)
|
mPort?.postMessage(json)
|
||||||
|
|||||||
@ -105,6 +105,7 @@ class PortMessage {
|
|||||||
var base64Data: String? = null
|
var base64Data: String? = null
|
||||||
var value : String? = null
|
var value : String? = null
|
||||||
var url : String? = null
|
var url : String? = null
|
||||||
|
var subTitles : List<Map<String, String>> = listOf()
|
||||||
}
|
}
|
||||||
class BookContents {
|
class BookContents {
|
||||||
var chapterTitle : String? = null
|
var chapterTitle : String? = null
|
||||||
|
|||||||
@ -407,6 +407,15 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
|||||||
fun setTypeface(tf: Typeface?) {
|
fun setTypeface(tf: Typeface?) {
|
||||||
mainTextView?.setTypeface(tf)
|
mainTextView?.setTypeface(tf)
|
||||||
sencondTextView?.setTypeface(tf)
|
sencondTextView?.setTypeface(tf)
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
invalidatePagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidatePagination() {
|
||||||
|
val currentText = text
|
||||||
|
text = "" // 트리거를 위해 비움
|
||||||
|
text = currentText // 다시 할당하여 paginateAsync 실행
|
||||||
}
|
}
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
@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.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContentProviderCompat.requireContext
|
||||||
import bums.lunatic.launcher.R
|
import bums.lunatic.launcher.R
|
||||||
|
import bums.lunatic.launcher.home.GeckoWeb
|
||||||
import bums.lunatic.launcher.player.NativePlayer.SubtitleTrack
|
import bums.lunatic.launcher.player.NativePlayer.SubtitleTrack
|
||||||
import bums.lunatic.launcher.utils.Blog
|
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.languageid.LanguageIdentification
|
||||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||||
import com.google.mlkit.nl.translate.Translation
|
import com.google.mlkit.nl.translate.Translation
|
||||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URLEncoder
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.nio.charset.CodingErrorAction
|
import java.nio.charset.CodingErrorAction
|
||||||
@ -36,6 +40,7 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private lateinit var videoTextureView: TextureView
|
private lateinit var videoTextureView: TextureView
|
||||||
|
private lateinit var geckoWeb: GeckoWeb
|
||||||
private lateinit var subtitleView: TextView
|
private lateinit var subtitleView: TextView
|
||||||
private lateinit var btnRotate: ImageButton
|
private lateinit var btnRotate: ImageButton
|
||||||
private lateinit var btnHideVideo: ImageButton
|
private lateinit var btnHideVideo: ImageButton
|
||||||
@ -80,6 +85,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
prepareEngine()
|
prepareEngine()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun prepareEngine() {
|
private fun prepareEngine() {
|
||||||
val videoFile = File(videoPath)
|
val videoFile = File(videoPath)
|
||||||
if (videoFile.exists()) {
|
if (videoFile.exists()) {
|
||||||
@ -141,7 +148,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
if (allSubtitleTracks.size > 1) {
|
if (allSubtitleTracks.size > 1) {
|
||||||
showSubtitleSelectionDialog()
|
showSubtitleSelectionDialog()
|
||||||
} else {
|
} else {
|
||||||
play()
|
|
||||||
|
showSubtitleSearchConfirmDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -158,6 +166,23 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
setupGestures()
|
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 seekBar: android.widget.SeekBar
|
||||||
private lateinit var tvTime: TextView
|
private lateinit var tvTime: TextView
|
||||||
private var uiUpdateJob: Job? = null
|
private var uiUpdateJob: Job? = null
|
||||||
@ -167,6 +192,9 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
|
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
|
||||||
|
|
||||||
videoTextureView = TextureView(this).apply { surfaceTextureListener = this@PlayerActivity }
|
videoTextureView = TextureView(this).apply { surfaceTextureListener = this@PlayerActivity }
|
||||||
|
geckoWeb = GeckoWeb(this).apply {
|
||||||
|
visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
subtitleView = TextView(this).apply {
|
subtitleView = TextView(this).apply {
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
@ -203,6 +231,7 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
|
root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
|
||||||
root.addView(subtitleView)
|
root.addView(subtitleView)
|
||||||
root.addView(gestureLayer)
|
root.addView(gestureLayer)
|
||||||
|
|
||||||
root.addView(btnRotate, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.START).apply { setMargins(30,0,0,30) })
|
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) })
|
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 {
|
val bottomControlLayout = android.widget.LinearLayout(this).apply {
|
||||||
@ -245,7 +274,7 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
bottomControlLayout.addView(seekBar)
|
bottomControlLayout.addView(seekBar)
|
||||||
|
|
||||||
root.addView(bottomControlLayout, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM))
|
root.addView(bottomControlLayout, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM))
|
||||||
|
root.addView(geckoWeb, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
|
||||||
setContentView(root)
|
setContentView(root)
|
||||||
|
|
||||||
hideSystemUI()
|
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() {
|
fun play() {
|
||||||
startUIUpdateLoop()
|
startUIUpdateLoop()
|
||||||
@ -397,7 +416,32 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
hideSystemUI()
|
hideSystemUI()
|
||||||
if (videoWidth > 0 && videoHeight > 0) adjustVideoAspectRatio(videoWidth, videoHeight)
|
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)
|
// 💡 화면 채움 (Fill/Crop)
|
||||||
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
|
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
@ -443,14 +487,94 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
private fun findSubtitleFile(videoPath: String): String {
|
private fun findSubtitleFile(videoPath: String): String {
|
||||||
val file = File(videoPath)
|
val file = File(videoPath)
|
||||||
val name = file.nameWithoutExtension
|
val name = file.nameWithoutExtension
|
||||||
val extensions = listOf("srt", "ass", "smi")
|
|
||||||
|
// 💡 번역된 파일을 최우선으로 탐색
|
||||||
|
val extensions = listOf("translated.srt", "srt", "ass", "smi")
|
||||||
|
|
||||||
for (ext in extensions) {
|
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
|
if (sub.exists()) return sub.absolutePath
|
||||||
}
|
}
|
||||||
return ""
|
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("\\{.*?\\}"), "")
|
private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "")
|
||||||
|
|
||||||
var lastSubTitle : String = ""
|
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> {
|
private fun parseSrt(file: File): List<SubtitleBlock> {
|
||||||
val result = mutableListOf<SubtitleBlock>()
|
val result = mutableListOf<SubtitleBlock>()
|
||||||
if (!file.exists()) return result
|
if (!file.exists()) return result
|
||||||
@ -709,8 +770,10 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveTranslatedSubtitle()
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
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() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
nativePlayer?.destroy()
|
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>? {
|
private fun getVideoSize(file: File): Pair<Int, Int>? {
|
||||||
val retriever = android.media.MediaMetadataRetriever()
|
val retriever = android.media.MediaMetadataRetriever()
|
||||||
@ -353,7 +353,7 @@ class MyWallpaperService : WallpaperService() {
|
|||||||
val wm = WallpaperManager.getInstance(this@MyWallpaperService)
|
val wm = WallpaperManager.getInstance(this@MyWallpaperService)
|
||||||
val requiredSize = Math.max(wm.desiredMinimumWidth, wm.desiredMinimumHeight) * requiredSizeRatio
|
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")
|
val imageExtensions = listOf("jpg", "jpeg", "png", "bmp", "webp")
|
||||||
|
|
||||||
for (file in allFiles) {
|
for (file in allFiles) {
|
||||||
@ -375,7 +375,7 @@ class MyWallpaperService : WallpaperService() {
|
|||||||
val size = getVideoSize(file)
|
val size = getVideoSize(file)
|
||||||
width = size?.first ?: 0
|
width = size?.first ?: 0
|
||||||
height = size?.second ?: 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]")
|
// Blog.LOGE("loadFiles videoExtensions requiredSize $requiredSize width $width height $height [$isOk]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -228,7 +228,7 @@ class TorrentService : Service() {
|
|||||||
|
|
||||||
// 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬
|
// 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬
|
||||||
if (isCharging) {
|
if (isCharging) {
|
||||||
val maxSlots = if (isWifiConnected) 3 else 1
|
val maxSlots = if (isWifiConnected) 6 else 2
|
||||||
val sortedByPriority = torrentsWithMetadata.sortedBy { it.second }
|
val sortedByPriority = torrentsWithMetadata.sortedBy { it.second }
|
||||||
|
|
||||||
sortedByPriority.forEachIndexed { index, pair ->
|
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:orientation="vertical"
|
||||||
android:padding="4dp"
|
android:padding="4dp"
|
||||||
android:background="?attr/selectableItemBackground">
|
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"/>
|
<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>
|
</LinearLayout>
|
||||||
@ -6,7 +6,23 @@
|
|||||||
android:padding="8dp"
|
android:padding="8dp"
|
||||||
android:background="?attr/selectableItemBackground"
|
android:background="?attr/selectableItemBackground"
|
||||||
android:gravity="center_vertical">
|
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">
|
<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/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"/>
|
<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