diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 158fa105..d70a96b1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -168,7 +168,9 @@ dependencies {
// implementation(project(":utils"))
implementation( "com.github.bumptech.glide:glide:4.11.0")
implementation ("com.github.bumptech.glide:okhttp3-integration:4.11.0")
-// implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407")
+ implementation ("com.github.chrisbanes:PhotoView:2.3.0")
+ implementation ("androidx.exifinterface:exifinterface:1.3.6")
+
// https://mvnrepository.com/artifact/org.mozilla.geckoview/geckoview
implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407")
implementation("com.vladsch.flexmark:flexmark-all:0.64.8")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 29a18ed2..1b8e1bc6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -85,7 +85,6 @@
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
- android:screenOrientation="nosensor"
android:windowSoftInputMode="adjustResize"
android:requestLegacyExternalStorage="true"
tools:ignore="HardcodedDebugMode">
@@ -126,6 +125,26 @@
android:exported="false">
+
+
+
+
+
+
{
var type= response["type"];
switch (type) {
+ case "GO_TO_SUBTITLE_DETAIL": {
+ const detailUrl = response["url"];
+ location.href = detailUrl; // 상세 페이지로 이동
+ break;
+ }
+ case "SEARCH_SUBTITLE_CAT": {
+ const query = response["query"];
+ const searchUrl = `https://www.subtitlecat.com/index.php?search=${encodeURIComponent(query)}`;
+ location.href = searchUrl; // 검색 페이지로 이동
+ break;
+ }
case "SCROLL_TOP": {
window.scrollTo({
top: 0,
@@ -638,6 +649,7 @@ function sendCookiesToNative() {
url: location.href
});
console.log("Cookies sent to native.");
+ scrollToEndAndExtract()
}
} catch (e) {
// 예외 무시
@@ -708,6 +720,68 @@ document.addEventListener('DOMContentLoaded', function () {
window.addEventListener('load', sendCookiesToNative);
}
})
+function extractSubtitleList() {
+ // 1. 현재 URL에서 'search' 파라미터(파일명) 추출
+ const urlParams = new URLSearchParams(window.location.search);
+ const searchQuery = urlParams.get('search');
+
+ if (location.host.includes("subtitlecat.com") && searchQuery) {
+ // tbody 안의 모든 행(tr)을 가져옴
+ const rows = Array.from(document.querySelectorAll('table.sub-table tbody tr'));
+
+ const subList = rows.map(row => {
+ const cols = row.querySelectorAll('td');
+ if (cols.length < 5) return null;
+
+ // 첫 번째 td 안에 태그와 (translated from ...) 텍스트가 있음
+ const linkEl = cols[0].querySelector('a');
+ const fullText = cols[0].innerText;
+
+ // 정규식을 사용하여 "translated from [언어]" 부분 추출
+ const langMatch = fullText.match(/\(translated from (.*?)\)/);
+ const originalLang = langMatch ? langMatch[1] : "Unknown";
+
+ return {
+ title: linkEl ? linkEl.innerText.trim() : "No Title",
+ downloadUrl: linkEl ? linkEl.href : null, // 상세 페이지 URL
+ lang: originalLang, // 원문 언어 정보
+ size: cols[2].querySelector('.sub-table__metric-value')?.innerText || "N/A",
+ downloads: cols[3].innerText.trim(),
+ originalFileName: decodeURIComponent(searchQuery) // 파일명 자동 매칭용
+ };
+ }).filter(item => item !== null && item.downloadUrl !== null);
+
+ if (subList.length > 0) {
+ sendMessage({
+ type: "SUBTITLE_LIST_RESULT",
+ list: subList
+ });
+ }
+ }
+}
+function scrollToEndAndExtract() {
+ const scrollStep = 800; // 한 번에 스크롤할 양
+ const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms)
+
+ function step() {
+ const prevScrollY = window.scrollY;
+ window.scrollBy(0, scrollStep);
+
+ // 💡 0.5초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작
+ setTimeout(() => {
+ if (window.scrollY === prevScrollY ||
+ (window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
+ console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작");
+ extractSubtitleList(); // 기존에 정의한 추출 함수 호출
+ } else {
+ step(); // 아직 끝이 아니면 다음 스크롤 진행
+ }
+ }, scrollDelay);
+ }
+
+ step();
+}
+
const keywords = ["youtube", "mojeek"];
const url = location.href;
diff --git a/app/src/main/assets/extensions/my_extension/sdsdsd.js b/app/src/main/assets/extensions/my_extension/sdsdsd.js
index 303dd32b..7a339c50 100644
--- a/app/src/main/assets/extensions/my_extension/sdsdsd.js
+++ b/app/src/main/assets/extensions/my_extension/sdsdsd.js
@@ -1,29 +1,131 @@
+(function() {
+ console.log("Enhanced Scroll & Click Loop Started");
-window.addEventListener('load',()=>{
- const container = document.getElementById('container');
- container.addEventListener('focus',(e)=> {
- window.onpopstate = function(event){
- try {
- var historyUrl = new URL(document.referrer);
- var referrerUrl = document.referrer;
- console.log(history.state);
- if(historyUrl.host != location.host || referrerUrl.includes('pntPointBankBill')){
- // 현재 페이지 상태를 히스토리에 추가
- history.pushState(null, null, location.href);
- // 뒤로가기 버튼 누를 때 호출되는 이벤트 처리
- // 홈으로 이동
- location.href = "/nhaob/main/main.nh";
- } else {
- history.pushState("BACK", null, "/nhaob/main/main.nh");
- console.log("did pushState");
+ const CLICK_INTERVAL = 1000;
+ const SCROLL_STEP = 300;
+
+ const selectors = [
+ 'a', 'button', 'a.btn', 'input[type="button"]',
+ 'input[type="submit"]', '[role="button"]'
+ ];
+
+ // --- 설정 구역 ---
+ const config = {
+ // 1. 특정 페이지에서 순서대로 클릭해야 하는 경우
+ sequences: {
+ "www.nhmembers.co.kr/nhaob": [".btn-direct-menu"],
+ "example.com/login": [".userid-input", ".password-input", "#login-btn"],
+ "mysite.com/survey": ["input[value='yes']", "button.next-step"]
+ },
+ // 2. 특정 페이지에서 절대 클릭하면 안 되는 요소 (예: 로그아웃, 삭제 버튼)
+ blacklist: {
+ "any": ["logout", "delete", "remove", "exit","header__prev main_header"], // 모든 페이지 공통 키워드
+ "runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"] // 특정 페이지 전용
+ }
+ };
+
+ let sequenceStep = 0;
+ let lastUrl = "";
+
+ const mainLoop = setInterval(() => {
+ const currentUrl = window.location.href;
+
+ // 페이지가 바뀌면 순서(Step) 초기화
+ if (currentUrl !== lastUrl) {
+ sequenceStep = 0;
+ lastUrl = currentUrl;
+ }
+
+ // --- 로직 1: 특정 주소에서의 순차 클릭 처리 ---
+ const activeSequenceKey = Object.keys(config.sequences).find(url => currentUrl.includes(url));
+
+ if (activeSequenceKey) {
+ const steps = config.sequences[activeSequenceKey];
+ if (sequenceStep < steps.length) {
+ const targetSelector = steps[sequenceStep];
+ const target = document.querySelector(targetSelector);
+
+ if (target && isVisible(target)) {
+ console.log(`[Sequence] Clicking step ` + sequenceStep + ' : ', targetSelector);
+ target.click();
+ sequenceStep++; // 다음 단계로
+ return; // 순차 클릭을 했을 경우 랜덤 클릭 건너뜀
}
- } catch (e) {
- console.log(e);
}
}
- })
- container.focus();
-})
-window.focus()
-document.body.focus()
+ // --- 로직 2: 일반 랜덤 클릭 (예외 처리 포함) ---
+ window.scrollBy(0, SCROLL_STEP);
+
+ const elements = document.querySelectorAll(selectors.join(','));
+ const visibleElements = Array.from(elements).filter(el => {
+ if (!isVisible(el)) return false;
+
+ // 현재 페이지에 적용할 블랙리스트 단어들 모으기
+ const pageBlacklist = config.blacklist["any"].concat(
+ Object.entries(config.blacklist)
+ .filter(([url]) => currentUrl.includes(url))
+ .flatMap(([_, tags]) => tags)
+ );
+
+ // 검사할 대상 문자열들을 배열로 만듦
+ const targetsToScan = [
+ el.innerText, // 버튼 위 텍스트
+ el.id, // ID
+ el.className.toString(), // 클래스명
+ el.getAttribute('alt'), // 이미지 대체 텍스트 (중요!)
+ el.getAttribute('title'), // 마우스 올리면 나오는 텍스트
+ el.getAttribute('aria-label') // 웹 접근성용 라벨
+ ].map(val => (val || "").toLowerCase()); // 소문자로 통일해서 비교
+
+ // 블랙리스트 단어가 위 항목 중 하나라도 포함되어 있는지 확인
+ const isBlacklisted = pageBlacklist.some(term =>
+ targetsToScan.some(content => content.includes(term.toLowerCase()))
+ );
+
+ return !isBlacklisted;
+ });
+
+ if (visibleElements.length > 0) {
+ const randomIndex = Math.floor(Math.random() * visibleElements.length);
+ const target = visibleElements[randomIndex];
+ console.log('Target Clicked:', target.innerText || target.tagName);
+ try { target.click(); } catch (e) { console.error(e); }
+ }
+
+ if ((window.innerHeight + window.pageYOffset) >= document.documentElement.scrollHeight) {
+ window.scrollTo(0, 0);
+ }
+
+ }, CLICK_INTERVAL);
+
+ // 가시성 확인 함수
+ function isVisible(el) {
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0 &&
+ rect.top >= 0 && rect.top <= window.innerHeight &&
+ window.getComputedStyle(el).visibility !== 'hidden' &&
+ window.getComputedStyle(el).display !== 'none';
+ }
+
+ window.autoCrawlerLoop = mainLoop;
+})();
+
+
+window.addEventListener('error', function(event) {
+ // 1. 일반적인 JS 문법/런타임 에러
+ if (event.message) {
+ const errorLog = {
+ type: 'JS_ERROR,
+ message: event.message,
+ source: event.filename,
+ line: event.lineno
+ };
+ console.warn(`Error: ` + event.message);
+ }
+ // 2. 리소스 로드 에러 (이미지, JS 파일이 없을 때)
+ else {
+ const target = event.target || event.srcElement;
+ console.warn('Resource Load Error:', target.src || target.href);
+ }
+}, true);
diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt
index 275cf203..bff2af27 100644
--- a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt
+++ b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt
@@ -7,9 +7,13 @@ import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Environment
+import android.os.ParcelFileDescriptor
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.Surface
+import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
@@ -22,12 +26,16 @@ import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import bums.lunatic.launcher.R
+import bums.lunatic.launcher.player.DocumentViewerActivity
+import bums.lunatic.launcher.player.ImageViewerActivity
+import bums.lunatic.launcher.player.NativePlayer
import bums.lunatic.launcher.player.PlayerActivity
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
@@ -78,6 +86,10 @@ enum class RenameMode(val label: String) {
SEQUENTIAL("순차적 직접 변경")
}
+private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
+private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
+private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp","srt", "smi", "ass", "vtt")
+
class CompletedFilesFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
@@ -96,9 +108,7 @@ class CompletedFilesFragment : Fragment() {
private var isSelectionMode = false
private val selectedFiles = mutableSetOf()
- private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
- private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
- private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
+
private lateinit var backPressedCallback: OnBackPressedCallback
fun backPress() = backPressedCallback.handleOnBackPressed()
@@ -143,6 +153,10 @@ class CompletedFilesFragment : Fragment() {
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
}
+ private fun showVideoPreviewDialog(file: File) {
+
+ }
+
private fun setupRecyclerView(view: View) {
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
updateRecyclerViewLayoutManager()
@@ -174,6 +188,16 @@ class CompletedFilesFragment : Fragment() {
putExtra("VIDEO_PATH", file.absolutePath)
}
startActivity(intent)
+ } else if(extImages.contains(file.extension.lowercase())) {
+ val intent = Intent(requireContext(), ImageViewerActivity::class.java).apply {
+ putExtra("IMAGE_PATH", file.absolutePath)
+ }
+ startActivity(intent)
+ } else if(extDocs.contains(file.extension.lowercase())) {
+ val intent = Intent(requireContext(), DocumentViewerActivity::class.java).apply {
+ putExtra("FILE_PATH", file.absolutePath)
+ }
+ startActivity(intent)
} else {
openPrivateFile(requireContext(), file) // 이미지나 문서는 기존처럼
}
@@ -536,28 +560,33 @@ class CompletedFilesFragment : Fragment() {
// 💡 보호 대상 필터링
val protectedCount = selectedFiles.count { isProtectedFile(it) }
- val filesToDelete = selectedFiles.filter { !isProtectedFile(it) }
+// val filesToDelete : MutableList = mutableListOf().apply {
+// addAll(selectedFiles.filter { !isProtectedFile(it) })
+// }
+ if (protectedCount == 1) {
- if (protectedCount > 0 && filesToDelete.isEmpty()) {
- Toast.makeText(context, "Images 및 Videos 폴더 내 항목은 개별 삭제만 가능합니다.", Toast.LENGTH_LONG).show()
- return@setOnClickListener
}
- val message = if (protectedCount > 0) {
- "보호된 항목 ${protectedCount}개를 제외하고 나머지 ${filesToDelete.size}개 항목을 삭제하시겠습니까?"
+ val message = if (protectedCount > 5) {
+ Toast.makeText(context, "보호된 폴더 내부는 5개 이하만 동시 삭제 가능함.", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
} else {
- "선택한 ${filesToDelete.size}개 항목을 삭제하시겠습니까?"
+ "선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?"
}
android.app.AlertDialog.Builder(requireContext())
.setMessage(message)
.setPositiveButton("삭제") { _, _ ->
var delCount = 0
- filesToDelete.forEach { file ->
- if (file.isDirectory) {
- if (file.deleteRecursively()) delCount++
- } else if (file.delete()) {
- delCount++
+ selectedFiles.forEach { file ->
+ if (isProtectedFolder(file)){
+
+ } else {
+ if (file.isDirectory) {
+ if (file.deleteRecursively()) delCount++
+ } else if (file.delete()) {
+ delCount++
+ }
}
}
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
@@ -815,64 +844,97 @@ class CompletedFilesFragment : Fragment() {
.show()
}
+ private fun getTargetFolderName(file: File): String {
+ val ext = file.extension.lowercase()
+ return when {
+ // 이미지 확장자 세트에 포함된 경우
+ extImages.contains(ext) -> "Images"
+
+ // 비디오 확장자 세트에 포함된 경우
+ extVideos.contains(ext) -> "Videos"
+
+ // 문서 확장자 세트에 포함된 경우
+ extDocs.contains(ext) -> "Documents"
+
+ // 그 외 모든 파일은 Etc 폴더로 분류
+ else -> "Etc"
+ }
+ }
+
private fun organizeRootFiles() {
CoroutineScope(Dispatchers.IO).launch {
- // 1. 루트 폴더의 파일 목록 가져오기
- val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
+ // 1. 기초 설정
+ val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
+ val trashDir = File(rootDir, "trash").apply { if (!exists()) mkdirs() }
+ val videoTargetDir = File(rootDir, "Videos").apply { if (!exists()) mkdirs() }
var movedCount = 0
- // 2. 널브러진 파일들을 확장자별 폴더로 이동
+ // [단계 1] 루트에 널브러진 파일 기본 정리
filesInRoot?.forEach { file ->
- val ext = file.extension.lowercase()
- val folderName = when {
- extImages.contains(ext) -> "Images"
- extVideos.contains(ext) -> "Videos"
- extDocs.contains(ext) -> "Documents"
- else -> "Etc"
- }
- val targetDir = File(rootDir, folderName)
- if (!targetDir.exists()) targetDir.mkdirs()
-
- if (file.renameTo(File(targetDir, file.name))) {
- movedCount++
- }
+ val folderName = getTargetFolderName(file)
+ val targetDir = File(rootDir, folderName).apply { if (!exists()) mkdirs() }
+ if (file.renameTo(File(targetDir, file.name))) movedCount++
}
- // 💡 3. 빈 폴더 싹쓸이 기능 추가 (Bottom-Up 방식)
- var deletedFolderCount = 0
+ // [단계 2] 특수 폴더 정리 및 잔여 파일 trash 이동
+ val subtitleExts = setOf("srt", "smi", "ass", "vtt", "txt")
+ val protectedDirs = setOf("Images", "Videos", "Documents", "Etc", "trash", "Youtube")
- // walkBottomUp()을 사용하면 가장 깊은 하위 폴더부터 위로 올라오면서 검사합니다.
- rootDir.walkBottomUp().forEach { dir ->
- if (dir != rootDir && dir.isDirectory) {
- // 💡 Images와 Videos 폴더는 비어있어도 삭제 대상에서 제외
- if (dir.parentFile == rootDir && (dir.name == "Images" || dir.name == "Videos")) {
- return@forEach
- }
+ rootDir.listFiles()?.filter { it.isDirectory && !protectedDirs.contains(it.name) }?.forEach { folder ->
+ val innerFiles = folder.listFiles() ?: return@forEach
- if (dir.listFiles()?.isEmpty() == true) {
- if (dir.delete()) {
- deletedFolderCount++
+ // 특수 조건(1GB 영상) 확인용 데이터
+ val videoFiles = innerFiles.filter { extVideos.contains(it.extension.lowercase()) }
+ val potentialSubtitles = innerFiles.filter { subtitleExts.contains(it.extension.lowercase()) }
+ val hasLargeVideo = videoFiles.any { it.length() >= 1024 * 1024 * 1024 }
+ val hasTinyText = potentialSubtitles.any { it.length() <= 1024 }
+
+ if (hasLargeVideo) {
+ // 조건 만족 시 영상+자막 이동
+ videoFiles.forEach { videoFile ->
+ if (videoFile.renameTo(File(videoTargetDir, videoFile.name))) {
+ movedCount++
+ val videoNameOnly = videoFile.nameWithoutExtension
+ potentialSubtitles.forEach { subFile ->
+// if (subFile.nameWithoutExtension.startsWith(videoNameOnly)) {
+ if (subFile.renameTo(File(videoTargetDir, subFile.name))) movedCount++
+// }
+ }
}
}
}
+
+ // [단계 3] 💡 위 로직에서 살아남은 나머지 잔여 파일들을 trash로 이동
+ // 이동 후 남은 파일을 다시 체크
+ folder.listFiles()?.filter { it.isFile }?.forEach { remainingFile ->
+ // 리네임 규칙: '원래폴더명_파일명'
+ val newName = "${folder.name}_${remainingFile.name}"
+ val destFile = File(trashDir, newName)
+
+ if (remainingFile.renameTo(destFile)) {
+ movedCount++
+ }
+ }
}
- // 4. 메인 스레드에서 결과 메시지 출력 및 리스트 갱신
- withContext(Dispatchers.Main) {
- if (movedCount > 0 || deletedFolderCount > 0) {
- val msg = buildString {
- if (movedCount > 0) append("${movedCount}개의 파일을 폴더로 정리했습니다.\n")
- if (deletedFolderCount > 0) append("${deletedFolderCount}개의 빈 폴더를 삭제했습니다.")
- }.trim()
+ // [단계 4] 빈 폴더 싹쓸이 (Bottom-Up)
+ var deletedFolderCount = 0
+ rootDir.walkBottomUp().forEach { dir ->
+ if (dir != rootDir && dir.isDirectory) {
+ // 보호된 폴더는 비어있어도 삭제 안 함
+ if (dir.parentFile == rootDir && protectedDirs.contains(dir.name)) return@forEach
- Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
-
- // 파일 이동이나 폴더 삭제가 하나라도 일어났다면 현재 화면 새로고침
- loadFiles()
- } else {
- Toast.makeText(requireContext(), "정리할 파일이나 빈 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
+ if (dir.listFiles()?.isEmpty() == true) {
+ if (dir.delete()) deletedFolderCount++
+ }
}
}
+
+ withContext(Dispatchers.Main) {
+ val msg = "${movedCount}개 항목 정리(trash 포함) 및 ${deletedFolderCount}개 빈 폴더 삭제 완료"
+ Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
+ loadFiles()
+ }
}
}
@@ -894,19 +956,22 @@ class CompletedFilesFragment : Fragment() {
if (!hidden) loadFiles()
}
- private fun isProtectedFile(file: File): Boolean {
- // 1. 루트의 Images, Videos 폴더 자체 보호
- val protectedFolders = setOf("Images", "Videos","Youtube")
+ val protectedFolders = setOf("Images", "Videos","Youtube")
+ private fun isProtectedFolder(file: File): Boolean {
if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
return true
}
+ return false
+ }
- // 2. Images나 Videos 폴더 안에 들어있는 파일/폴더들 보호
+ private fun isProtectedFile(file: File): Boolean {
+ if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
+ return true
+ }
val parent = file.parentFile
if (parent != null && parent.parentFile == rootDir && protectedFolders.contains(parent.name)) {
return true
}
-
return false
}
@@ -1092,7 +1157,40 @@ class CompletedFilesAdapter(
Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
}
}
+ var localPlayer: NativePlayer? = null
+ val textureView = itemView.findViewById(R.id.previewTextureView)
+ ivThumb?.setOnTouchListener { v, event ->
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ // 💡 0.5초 뒤에도 누르고 있다면 재생 시작
+ v.postDelayed({
+ if (v.isPressed && extVideos.contains(file.extension.lowercase())) {
+ textureView.visibility = View.VISIBLE
+ localPlayer = NativePlayer().apply {
+ initialize()
+ val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ setDataSource(pfd.detachFd(), -1)
+ onPreparedListener = {
+ seekTo(60.0) // 1분 지점
+ play(Surface(textureView.surfaceTexture))
+ }
+ prepareAsync()
+ }
+ }
+ }, 500)
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ // 💡 손을 떼면 즉시 뷰 숨기고 플레이어 파괴
+ v.isPressed = false
+ textureView.visibility = View.GONE
+ localPlayer?.stop()
+ localPlayer?.destroy()
+ localPlayer = null
+ }
+ }
+ true // 이벤트를 소비하여 롱클릭/클릭 충돌 방지 (필요 시 조정)
+ }
itemView.setOnClickListener { onItemClick(file) }
itemView.setOnLongClickListener {
if (file.name != "..") onItemLongClick(file)
diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt
index 949c6c8d..4899cc3e 100644
--- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt
+++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt
@@ -28,6 +28,7 @@ import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContentProviderCompat.requireContext
import androidx.core.net.toUri
import androidx.core.view.isVisible
import bums.lunatic.launcher.BookmarkUploader
@@ -41,8 +42,10 @@ import bums.lunatic.launcher.home.tokiz.PortMessage
import bums.lunatic.launcher.model.Dotax
import bums.lunatic.launcher.model.DotaxArticles
import bums.lunatic.launcher.model.getRssData
+import bums.lunatic.launcher.player.PlayerActivity
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CommonUtils
+import bums.lunatic.launcher.utils.FileUtils
import bums.lunatic.launcher.utils.SimpleFingerGestures
import bums.lunatic.launcher.workers.TorrentService
import bums.lunatic.launcher.workers.WorkersDb
@@ -56,6 +59,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
@@ -330,7 +334,15 @@ open class GeckoWeb @JvmOverloads constructor(
}
override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
if (response.uri.contains(".apk")) return
+ val url = response.uri
+ // 💡 .srt 파일인 경우 플레이어 로직과 연결
+ if (url.endsWith(".srt", ignoreCase = true) || url.contains("download")) {
+ (context as? PlayerActivity)?.let { player ->
+ player.downloadSubtitle(url)
+ }
+ } else {
downloadFile(response.uri)
+ }
}
override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, element: GeckoSession.ContentDelegate.ContextElement) {
val pageUrl = element.baseUri ?: lastedUrl ?: return
@@ -612,8 +624,42 @@ open class GeckoWeb @JvmOverloads constructor(
null
}
}
+
+ private fun downloadSubtitleWithReferer(url: String, referer: String?) {
+ // 영상 파일명과 매칭하기 위해 PlayerActivity의 videoPath 정보 활용 필요
+ val filename = "downloaded_subtitle.srt"
+ val savePath = File(context.getExternalFilesDir(null), filename)
+
+ Thread {
+ try {
+ val request = Request.Builder()
+ .url(url)
+ .addHeader("Referer", referer ?: "")
+ .build()
+ val response = OkHttpClient().newCall(request).execute()
+ if (response.isSuccessful) {
+ response.body?.byteStream()?.use { input ->
+ FileOutputStream(savePath).use { output -> input.copyTo(output) }
+ }
+ post { context.toast("자막 다운로드 완료!") }
+ // 이후 PlayerActivity에 알림을 주어 자막 로드 실행
+ }
+ } catch (e: Exception) { e.printStackTrace() }
+ }.start()
+ }
+
+
+
private fun handlePortMessage(msg: PortMessage) {
when (msg.type) {
+ "SUBTITLE_LIST_RESULT" -> {
+ (context as? PlayerActivity)?.let { player ->
+ // PlayerActivity에 리스트 전달
+ player.runOnUiThread {
+ player.showDownSubtitleSelectionDialog(msg.subTitles)
+ }
+ }
+ }
"COOKIES_REPORT"-> {
// Blog.LOGE("${msg.value} -> ${msg.url}")
currentCookieString = msg.value ?: ""
@@ -675,7 +721,7 @@ open class GeckoWeb @JvmOverloads constructor(
fun sendSearchDo() = sendJsonMsg("searchDo")
fun saveMd(fast: Boolean? = false) = sendJsonMsg("saveContent", "fast" to fast)
- private fun sendJsonMsg(type: String, vararg params: Pair) {
+ fun sendJsonMsg(type: String, vararg params: Pair) {
val json = JSONObject().put("type", type)
params.forEach { json.put(it.first, it.second) }
mPort?.postMessage(json)
diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt
index 23bb0a07..bc6d475e 100644
--- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt
+++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt
@@ -105,6 +105,7 @@ class PortMessage {
var base64Data: String? = null
var value : String? = null
var url : String? = null
+ var subTitles : List