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

View File

@ -168,7 +168,9 @@ dependencies {
// implementation(project(":utils")) // implementation(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")

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,290 @@
package bums.lunatic.launcher.player
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import bums.lunatic.launcher.home.tokiz.TouchArea
import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
import bums.lunatic.launcher.home.tokiz.view.PagedTextViewInterface
import bums.lunatic.launcher.model.Translation
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.FileUtils
import bums.lunatic.launcher.utils.FileUtils.charsets
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
import com.frostwire.jlibtorrent.swig.operation_t.file
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.TranslatorOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.charset.Charset
import java.text.SimpleDateFormat
class DocumentViewerActivity : AppCompatActivity() {
private lateinit var pagedLayout: PagedTextLayout
private lateinit var header: View
private val handler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideOverlay() }
private var currentFile: File? = null
private var currentRawBytes: ByteArray? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_document_viewer)
pagedLayout = findViewById(R.id.pagedTextLayout)
header = findViewById(R.id.layoutDocHeader)
pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
// 필요하다면 줄 간격이나 자간도 기본값으로 조정하여 가독성을 높입니다.
pagedLayout.setLineSpacing(20f) // 1.2배 정도의 줄간격
pagedLayout.setLetterSpacing(0f) // 기본 자간
val filePath = intent.getStringExtra("FILE_PATH") ?: return finish()
currentFile = File(filePath)
currentRawBytes = currentFile?.readBytes()
// 초기 자동 로드
pagedLayout.text = FileUtils.readTextWithEncoding(currentFile!!)
// 💡 인코딩 수동 선택 버튼 (예: 헤더의 특정 아이콘 클릭 시)
findViewById<View>(R.id.btnChangeEncoding).setOnClickListener {
showAdvancedEncodingDialog()
}
currentFile?.let { currentFile ->
val content = readTextWithEncoding(currentFile)
pagedLayout.text = content
// 2. 헤더 정보 표시
findViewById<TextView>(R.id.tvDocTitle).text = currentFile.name
val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm").format(currentFile.lastModified())
findViewById<TextView>(R.id.tvDocMeta).text = "수정일: $dateStr | 크기: ${currentFile.length() / 1024} KB"
}
showOverlay()
// 3. 제스처 인터페이스 설정
pagedLayout.mPagedTextViewInterface = object : PagedTextViewInterface {
override fun onTouch(touchArea: TouchArea) {
if (header.visibility == View.VISIBLE) hideOverlay() else showOverlay()
}
override fun onSwipeLeft(count: Int) { pagedLayout.doNext() }
override fun onSwipeRight(count: Int) { pagedLayout.doPrev() }
override fun onLongClick() { finish() } // 잠깐 확인용이므로 롱클릭 시 종료
override fun onTimeoverTouch() {}
override fun onSwipeDown(count: Int) {}
override fun onSwipeUp(count: Int) {}
}
}
private val fullEncodingList = mapOf(
"추천 (자동)" to listOf("UTF-8", "GB18030", "CP949", "BIG5", "Shift_JIS"),
"한국/중국/일본" to listOf("EUC-KR", "GBK", "EUC-JP", "ISO-2022-JP"),
"영어/서유럽" to listOf("ISO-8859-1", "Windows-1252", "ISO-8859-15"),
"유니코드/기타" to listOf("UTF-16LE", "UTF-16BE", "UTF-32")
)
private val flatEncodingList = fullEncodingList.values.flatten().distinct()
private fun showAdvancedEncodingDialog() {
var selectedIndex = 0
val items = flatEncodingList.toTypedArray()
android.app.AlertDialog.Builder(this)
.setTitle("인코딩 선택 (화면을 보며 확인하세요)")
.setSingleChoiceItems(items, -1) { _, which ->
selectedIndex = which
applyPreviewEncoding(items[which])
}
.setPositiveButton("확정 및 처리") { _, _ ->
// 💡 인코딩 확정 후 다음 액션 선택
showActionSelectionDialog(items[selectedIndex])
}
.setNeutralButton("다음 인코딩") { _, _ ->
selectedIndex = (selectedIndex + 1) % items.size
applyPreviewEncoding(items[selectedIndex])
// 다이얼로그 유지를 위해 재호출 로직 필요 시 추가
}
.setNegativeButton("취소", null)
.show()
}
private fun showActionSelectionDialog(charsetName: String) {
android.app.AlertDialog.Builder(this)
.setTitle("처리 방식 선택")
.setMessage("[$charsetName] 인코딩으로 확인되었습니다.\n어떤 방식으로 저장할까요?")
.setPositiveButton("번역 후 저장") { _, _ ->
// 💡 언어 감지 후 번역 진행
detectLanguageAndTranslate(charsetName)
}
.setNeutralButton("그냥 이대로 저장") { _, _ ->
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName)
}
.setNegativeButton("취소", null)
.show()
}
private fun detectLanguageAndTranslate(charsetName: String) {
val textSample = pagedLayout.text.toString().take(2000) // 💡 정확도를 위해 앞부분 2000자 샘플링
if (textSample.isBlank()) return
val languageIdentifier = LanguageIdentification.getClient()
languageIdentifier.identifyLanguage(textSample)
.addOnSuccessListener { languageCode ->
if (languageCode == "und") {
Toast.makeText(this, "언어를 판별할 수 없습니다. 기본값(중국어)으로 시도합니다.", Toast.LENGTH_SHORT).show()
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE)
} else if (languageCode == "ko") {
Toast.makeText(this, "이미 한국어 문서입니다. 번역 없이 저장합니다.", Toast.LENGTH_SHORT).show()
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName)
} else {
// 💡 감지된 언어 코드를 번역기 코드로 변환
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
if (sourceLang != null) {
translateAndSaveByParagraph(charsetName, sourceLang)
} else {
Toast.makeText(this, "지원하지 않는 언어($languageCode)입니다.", Toast.LENGTH_SHORT).show()
}
}
}
.addOnFailureListener {
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE) // 실패 시 기본값
}
}
private fun translateAndSaveByParagraph(charsetName: String, sourceLang: String) {
val originalFile = currentFile ?: return
// 💡 감지된 sourceLang 적용
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLang)
.setTargetLanguage(TranslateLanguage.KOREAN) // 타겟은 무조건 한국어!
.build()
val translator = com.google.mlkit.nl.translate.Translation.getClient(options)
val newFileName = "${originalFile.nameWithoutExtension}_translated_ko.txt"
val newFile = File(originalFile.parent, newFileName)
Toast.makeText(this, "[$sourceLang] 번역 작업 시작...", Toast.LENGTH_SHORT).show()
CoroutineScope(Dispatchers.IO).launch {
try {
Tasks.await(translator.downloadModelIfNeeded())
originalFile.inputStream().bufferedReader(Charset.forName(charsetName)).use { reader ->
newFile.outputStream().bufferedWriter(Charsets.UTF_8).use { writer ->
val paragraphBuilder = StringBuilder()
reader.forEachLine { line ->
if (line.isBlank()) {
if (paragraphBuilder.isNotEmpty()) {
// 💡 문단 단위 번역 (문맥 유지)
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
writer.newLine()
writer.newLine()
paragraphBuilder.clear()
}
} else {
paragraphBuilder.append(line).append(" ")
}
if (paragraphBuilder.length > 1000) { // 💡 너무 긴 문단 방지
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
paragraphBuilder.clear()
}
}
if (paragraphBuilder.isNotEmpty()) {
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
}
}
}
withContext(Dispatchers.Main) {
Toast.makeText(this@DocumentViewerActivity, "번역 완료!", Toast.LENGTH_SHORT).show()
currentFile = newFile
pagedLayout.text = FileUtils.readTextWithEncoding(newFile)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@DocumentViewerActivity, "번역 실패: ${e.message}", Toast.LENGTH_SHORT).show()
}
} finally {
translator.close()
}
}
}
var lastEncoded = ""
private fun applyPreviewEncoding(charset: String) {
val bytes = currentRawBytes ?: return
lastEncoded = charset
try {
val decoder = Charset.forName(charset).newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
.replaceWith("")
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
pagedLayout.text = text
} catch (e: Exception) {
Blog.LOGE("미리보기 실패: $charset")
}
}
private fun saveCurrentTextAsUtf8(validText: String, charsetName: String) {
val originalFile = currentFile ?: return
try {
// 1. 새 파일명 생성
val newFileName = "${originalFile.nameWithoutExtension}_${charsetName}.${originalFile.extension}"
val newFile = File(originalFile.parent, newFileName)
// 2. 스트림을 이용한 라인 단위 읽기 및 쓰기
// 원본을 선택한 인코딩(charsetName)으로 읽어서 UTF-8로 씁니다.
originalFile.inputStream().bufferedReader(Charset.forName(charsetName)).use { reader ->
newFile.outputStream().bufferedWriter(Charsets.UTF_8).use { writer ->
reader.forEachLine { line ->
writer.write(line)
writer.newLine()
}
}
}
Toast.makeText(this, "새 파일로 저장 완료:\n$newFileName", Toast.LENGTH_LONG).show()
// 3. 화면 갱신을 위해 새 파일 로드
currentFile = newFile
pagedLayout.text = FileUtils.readTextWithEncoding(newFile)
} catch (e: Exception) {
Toast.makeText(this, "저장 중 오류: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
private fun showOverlay() {
handler.removeCallbacks(hideRunnable)
header.visibility = View.VISIBLE
header.animate().alpha(1f).setDuration(300).start()
handler.postDelayed(hideRunnable, 3000)
}
private fun hideOverlay() {
header.animate().alpha(0f).setDuration(300).withEndAction {
header.visibility = View.GONE
}.start()
}
}

View File

@ -0,0 +1,112 @@
package bums.lunatic.launcher.player
import android.media.ExifInterface
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import com.bumptech.glide.Glide
import java.io.File
class ImageViewerActivity : AppCompatActivity() {
private lateinit var layoutMetaInfo: View
private val hideHandler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideMetaInfo() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_image_viewer)
val photoView = findViewById<com.github.chrisbanes.photoview.PhotoView>(R.id.photoView)
layoutMetaInfo = findViewById(R.id.layoutMetaInfo)
val btnToggleMeta = findViewById<ImageButton>(R.id.btnToggleMeta)
val imagePath = intent.getStringExtra("IMAGE_PATH") ?: return
// 1. 이미지 로드
Glide.with(this).load(File(imagePath)).into(photoView)
// 2. 메타데이터(Exif) 추출 및 표시
displayExifInfo(imagePath)
// 3. 초기 실행 시 3초간 보여주기
showMetaInfoWithDelay()
// 4. 버튼 클릭 리스너
btnToggleMeta.setOnClickListener {
if (layoutMetaInfo.visibility == View.VISIBLE) {
hideMetaInfo()
} else {
showMetaInfoWithDelay()
}
}
}
private fun getAllExifDetails(path: String): String {
val exif = androidx.exifinterface.media.ExifInterface(path)
val sb = StringBuilder()
// 1. 리플렉션을 사용하여 ExifInterface의 모든 TAG_ 필드를 가져옴
val fields = androidx.exifinterface.media.ExifInterface::class.java.fields
for (field in fields) {
if (field.name.startsWith("TAG_")) {
try {
val tagName = field.get(null) as String
val value = exif.getAttribute(tagName)
// 값이 있는 태그만 추가
if (!value.isNullOrBlank()) {
// 태그명에서 "TAG_" 접두어 제거하고 읽기 쉽게 변환 (예: TAG_MODEL -> Model)
val friendlyName = field.name.removePrefix("TAG_").replace("_", " ")
sb.append("**$friendlyName**: $value\n")
}
} catch (e: Exception) {
// 특정 태그 접근 실패 시 무시
}
}
}
// 2. 위치 정보는 별도 계산 (문자열보다 좌표값이 정확함)
val latLong = FloatArray(2)
if (exif.getLatLong(latLong)) {
sb.append("\n📍 **위치(GPS)**: ${latLong[0]}, ${latLong[1]}")
}
return if (sb.isEmpty()) "메타데이터 정보가 없습니다." else sb.toString()
}
private fun displayExifInfo(path: String) {
val exif = androidx.exifinterface.media.ExifInterface(path)
val info = StringBuilder().apply {
appendLine("모델: ${exif.getAttribute(ExifInterface.TAG_MODEL) ?: "알 수 없음"}\n")
appendLine("날짜: ${exif.getAttribute(ExifInterface.TAG_DATETIME) ?: "-"}\n")
appendLine("해상도: ${exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)}x${exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)}\n")
appendLine("조리개: f/${exif.getAttribute(ExifInterface.TAG_F_NUMBER) ?: "-"}")
appendLine(getAllExifDetails(path))
}.toString()
findViewById<TextView>(R.id.tvMetaTitle).text = File(path).name
findViewById<TextView>(R.id.tvMetaData).text = info
}
private fun showMetaInfoWithDelay() {
hideHandler.removeCallbacks(hideRunnable) // 기존 예약된 숨김 제거
layoutMetaInfo.visibility = View.VISIBLE
layoutMetaInfo.animate().alpha(1f).setDuration(300).start()
// 3초 후 숨김 예약
hideHandler.postDelayed(hideRunnable, 5000)
}
private fun hideMetaInfo() {
layoutMetaInfo.animate().alpha(0f).setDuration(300).withEndAction {
layoutMetaInfo.visibility = View.GONE
}.start()
}
}

View File

@ -14,15 +14,19 @@ import android.widget.ImageButton
import android.widget.TextView import android.widget.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()

View File

@ -0,0 +1,53 @@
package bums.lunatic.launcher.utils
import java.io.File
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
object FileUtils {
val charsets = listOf("UTF-8", "GB18030", "CP949", "BIG5", "Shift_JIS", "CP949", "Shift_JIS", "ISO-8859-1", "Windows-1252", "EUC-KR","GB2312","65001" )
fun readTextWithEncoding(file: File): String {
val bytes = file.readBytes()
if (bytes.isEmpty()) return ""
// 1. BOM 체크는 가장 정확하므로 유지
if (bytes.size >= 3 && bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte()) {
return String(bytes, 3, bytes.size - 3, Charsets.UTF_8)
}
// 2. 인코딩 후보 순서 (이미지 같은 자막은 GB18030이 강력한 후보입니다)
var bestText = ""
for (charsetName in charsets) {
try {
val decoder = java.nio.charset.Charset.forName(charsetName).newDecoder()
// 💡 핵심: REPLACE 대신 REPORT를 사용해 엄격하게 검사합니다.
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
// 인코딩이 조금이라도 어긋나면 여기서 Exception이 발생해 다음으로 넘어갑니다.
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
// 💡 추가 검증: 특수 기호(Ã, Â) 비율이 너무 높으면 중국어/한국어일 확률이 큼
if (isNaturalText(text)) {
return text
}
bestText = text
} catch (e: Exception) {
continue // 다음 인코딩으로
}
}
return if (bestText.isNotEmpty()) bestText else String(bytes, Charsets.UTF_8)
}
// 💡 텍스트가 깨진 외계어인지 확인하는 보조 함수
private fun isNaturalText(text: String): Boolean {
val garbageCount = text.count { it == 'Ã' || it == 'Â' || it == 'æ' }
// 전체 텍스트에서 저런 문자가 3% 이상 섞여있다면 깨진 것으로 간주
return garbageCount < (text.length / 30)
}
}

View File

@ -327,7 +327,7 @@ class MyWallpaperService : WallpaperService() {
} }
val requiredSizeRatio = 0.5 val requiredSizeRatio = 0.4
private fun getVideoSize(file: File): Pair<Int, Int>? { 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) {

View File

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

View File

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

View File

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

View File

@ -5,6 +5,21 @@
android:orientation="vertical" android: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>

View File

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