...
This commit is contained in:
@@ -144,12 +144,12 @@
|
||||
android:theme="@style/Theme.Player"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:roundIcon="@drawable/ic_launcher"
|
||||
android:label="리더"
|
||||
android:label="파일즈"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|fontScale|keyboardHidden|keyboard|layoutDirection|navigation"
|
||||
android:screenOrientation="portrait"
|
||||
android:excludeFromRecents="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:taskAffinity="bums.lunatic.launcher.home.WebReaderActivity"
|
||||
android:taskAffinity="bums.lunatic.launcher.home.CompletedFilesActivity"
|
||||
android:launchMode="singleTask"> <intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
Binary file not shown.
@@ -22,7 +22,8 @@
|
||||
"*://newtoki1.org/novel/*",
|
||||
"*://*.newtoki1.org/novel/*",
|
||||
"*://toki35.com/novel/*",
|
||||
"*://sbxh9.com/novel/*"
|
||||
"*://sbxh9.com/novel/*",
|
||||
"*://toki36.com/novel/*"
|
||||
],
|
||||
"js": ["inject.js"],
|
||||
"run_at": "document_start",
|
||||
@@ -33,7 +34,8 @@
|
||||
"*://newtoki1.org/novel/*",
|
||||
"*://*.newtoki1.org/novel/*",
|
||||
"*://toki35.com/novel/*",
|
||||
"*://sbxh9.com/novel/*"
|
||||
"*://sbxh9.com/novel/*",
|
||||
"*://toki36.com/novel/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_end"
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -1,16 +1,2 @@
|
||||
chrome.webRequest.onBeforeRequest.addListener(
|
||||
function(details) {
|
||||
if (details.type === "main_frame") {
|
||||
const currentUrl = details.originUrl || details.documentUrl || "";
|
||||
|
||||
// 현재 URL에 'darama'가 포함되어 있는데, 이동할 URL에는 'darama'가 없다면 차단
|
||||
if (currentUrl.includes("darama") && !details.url.includes("darama")) {
|
||||
console.log("Redirect blocked for URL:", details.url);
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ urls: ["<all_urls>"] },
|
||||
["blocking"] // 요청을 가로채고 취소할 수 있는 권한
|
||||
);
|
||||
// Manifest V3에서는 네트워크 차단을 rules.json이 담당하므로 이 파일은 비워둡니다.
|
||||
console.log("Darama AdBlocker 서비스 워커가 실행되었습니다.");
|
||||
@@ -0,0 +1,110 @@
|
||||
// ==UserScript==
|
||||
// @name Darama Safe Blocker
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.7
|
||||
// @description 사파리 @allFrames 적용 및 투명 iframe/새 탭 링크 원천 제거
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
// @inject-into page
|
||||
// @allFrames true
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 1. 페이지가 그려지기도 전에 CSS로 투명 오버레이 iframe과 외부 새 탭 링크의 터치 판정을 완전히 제거
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* 처음에 발견된 전체화면 투명 오버레이 iframe 무력화 */
|
||||
iframe[style*="2147483647"],
|
||||
iframe[style*="position: fixed"],
|
||||
iframe[style*="position:fixed"],
|
||||
iframe[src="about:blank"],
|
||||
iframe:not([src]),
|
||||
/* 외부 도메인으로 새 탭을 여는 투명 링크들의 터치 판정 제거 */
|
||||
a[target="_blank"]:not([href*="dramadunyam.com"]) {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
opacity: 0 !important;
|
||||
z-index: -999999 !important;
|
||||
}
|
||||
`;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// 2. window.open 및 동적 a 태그 생성 원천 봉쇄
|
||||
window.open = function() { return null; };
|
||||
|
||||
const origCreateElement = document.createElement;
|
||||
document.createElement = function(tagName) {
|
||||
const el = origCreateElement.apply(this, arguments);
|
||||
if (String(tagName).toLowerCase() === 'a') {
|
||||
// 광고 스크립트가 메모리 상에서 몰래 <a> 태그를 만들어 클릭시키는 수법 차단
|
||||
Object.defineProperty(el, 'target', {
|
||||
get: () => '_self',
|
||||
set: () => {}
|
||||
});
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
// 3. 손가락이 화면에 닿는 순간(touchstart, pointerdown) 모든 a 태그의 새 탭 속성 강제 삭제
|
||||
const neutralizeLink = (e) => {
|
||||
// Shadow DOM 내부의 링크까지 추적
|
||||
const path = e.composedPath ? e.composedPath() : [e.target];
|
||||
for (const node of path) {
|
||||
if (node && node.tagName === 'A') {
|
||||
const href = node.href || '';
|
||||
if (node.target === '_blank' || (href.startsWith('http') && !href.includes('dramadunyam.com'))) {
|
||||
node.removeAttribute('target');
|
||||
node.removeAttribute('href');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
['touchstart', 'pointerdown', 'mousedown', 'touchend', 'mouseup', 'click'].forEach(evt => {
|
||||
window.addEventListener(evt, neutralizeLink, { capture: true, passive: false });
|
||||
});
|
||||
|
||||
// 4. 동적으로 삽입되는 투명 iframe 실시간 제거 (일반 div나 플레이어는 건드리지 않음)
|
||||
const killAdIframes = () => {
|
||||
// 모든 외부 _blank 링크 무력화
|
||||
document.querySelectorAll('a[target="_blank"]').forEach(a => {
|
||||
if (!a.href || !a.href.includes('dramadunyam.com')) {
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('href');
|
||||
a.style.setProperty('pointer-events', 'none', 'important');
|
||||
}
|
||||
});
|
||||
|
||||
// 투명 덮개 iframe만 정밀 삭제
|
||||
document.querySelectorAll('iframe').forEach(iframe => {
|
||||
const s = iframe.getAttribute('style') || '';
|
||||
const src = iframe.getAttribute('src') || '';
|
||||
const comp = window.getComputedStyle(iframe);
|
||||
|
||||
// 화면에 고정(fixed)되어 있거나 z-index가 비정상적으로 높은 투명 iframe 타격
|
||||
if (
|
||||
s.includes('2147483647') ||
|
||||
s.includes('fixed') ||
|
||||
comp.position === 'fixed' ||
|
||||
parseInt(comp.zIndex) > 9999 ||
|
||||
!src ||
|
||||
src === 'about:blank'
|
||||
) {
|
||||
iframe.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(killAdIframes);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
|
||||
setInterval(killAdIframes, 150);
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
chrome.webRequest.onBeforeRequest.addListener(
|
||||
function(details) {
|
||||
if (details.type === "main_frame") {
|
||||
const currentUrl = details.originUrl || details.documentUrl || "";
|
||||
|
||||
// 현재 URL에 'dramadunyam'이 포함되어 있는데, 이동할 URL에는 'dramadunyam'이 없다면 외부 사이트로 간주하고 차단
|
||||
if (currentUrl.includes("dramadunyam") && !details.url.includes("dramadunyam")) {
|
||||
console.log("외부 팝업/리다이렉트 차단됨:", details.url);
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ urls: ["<all_urls>"] },
|
||||
["blocking"]
|
||||
);
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"version": "1.0",
|
||||
"name": "Darama AdBlocker (Orion)",
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"run_at": "document_start",
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["messaging.js"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 1. 투명 링크(a 태그)를 통한 새 탭 열기 완벽 차단
|
||||
document.addEventListener('click', function(event) {
|
||||
const aTag = event.target.closest('a');
|
||||
|
||||
if (aTag) {
|
||||
const href = aTag.href || "";
|
||||
const currentDomain = location.hostname.replace("www.", "");
|
||||
|
||||
if (aTag.target === '_blank' && href && !href.includes(currentDomain) && !href.startsWith("/")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log("투명 a 태그 외부 광고 클릭 차단됨:", href);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
|
||||
// 2. 자바스크립트(window.open)를 통한 팝업/새 탭 열기 강제 무력화
|
||||
const script = document.createElement('script');
|
||||
script.textContent = `
|
||||
window.open = function() {
|
||||
console.log("window.open을 통한 새 탭/팝업 열기 차단됨");
|
||||
return null;
|
||||
};
|
||||
`;
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
script.remove();
|
||||
|
||||
|
||||
// 3. 투명 오버레이 덮개 광고 강제 삭제
|
||||
function removeInvisibleOverlays() {
|
||||
const elements = document.querySelectorAll('iframe, div, a');
|
||||
elements.forEach(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (
|
||||
style.zIndex >= 999 &&
|
||||
(style.position === 'fixed' || style.position === 'absolute') &&
|
||||
(style.width === '100%' || parseInt(style.width) >= window.innerWidth * 0.9)
|
||||
) {
|
||||
console.log("화면 덮개형 광고 요소 강제 삭제됨:", el);
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 4. 공통 광고 요소 및 오버레이 제거 실행 함수
|
||||
function handleCommon(parentElement) {
|
||||
if (!parentElement.querySelectorAll) return;
|
||||
removeInvisibleOverlays();
|
||||
}
|
||||
|
||||
|
||||
// 5. 페이지 로드 시 초기 정리 작업 실행
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
handleCommon(document);
|
||||
});
|
||||
|
||||
|
||||
// 6. DOM 변화를 감시하여 동적으로 추가되는 아이프레임 광고 차단
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
// dramadunyam 도메인에서 동적으로 삽입되는 iframe 즉시 삭제
|
||||
if (location.href.includes("dramadunyam")) {
|
||||
if (node.tagName === 'IFRAME') {
|
||||
node.remove();
|
||||
} else if (node.querySelectorAll) {
|
||||
node.querySelectorAll('iframe').forEach(iframe => iframe.remove());
|
||||
}
|
||||
}
|
||||
|
||||
handleCommon(node);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// 1. 투명 오버레이 덮개 광고 및 불필요한 iframe 제거
|
||||
function cleanUpAds() {
|
||||
// 화면 전체를 덮는 투명 막(div, a, iframe) 제거
|
||||
const elements = document.querySelectorAll('iframe, div, a');
|
||||
elements.forEach(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (
|
||||
parseInt(style.zIndex) >= 999 &&
|
||||
(style.position === 'fixed' || style.position === 'absolute') &&
|
||||
(style.width === '100%' || parseInt(style.width) >= window.innerWidth * 0.9)
|
||||
) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// 동적으로 삽입되는 광고용 iframe 제거
|
||||
document.querySelectorAll('iframe').forEach(iframe => {
|
||||
const src = iframe.getAttribute('src') || '';
|
||||
// 본문 영상 플레이어가 아닌 빈 iframe이나 광고성 iframe 삭제
|
||||
if (!src || src === 'about:blank' || parseInt(window.getComputedStyle(iframe).zIndex) > 100) {
|
||||
iframe.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 실행
|
||||
cleanUpAds();
|
||||
|
||||
// 2. 화면 조작(클릭 등) 시 동적으로 추가되는 광고 실시간 감시 및 삭제
|
||||
if (document.body) {
|
||||
const observer = new MutationObserver(() => {
|
||||
cleanUpAds();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 1. 자바스크립트 window.open 무력화
|
||||
window.open = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
// 2. 외부로 이동하는 새 탭(_blank) 링크 클릭 가로채기
|
||||
window.addEventListener('click', function(event) {
|
||||
const aTag = event.target.closest ? event.target.closest('a') : null;
|
||||
if (aTag) {
|
||||
const href = aTag.href || "";
|
||||
const currentDomain = location.hostname.replace("www.", "");
|
||||
if (aTag.target === '_blank' && href && !href.includes(currentDomain) && !href.startsWith("/")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Darama support",
|
||||
"version": "1.2",
|
||||
"description": "dramadunyam.com 전용 투명 아이프레임 및 새 탭 광고 차단기",
|
||||
"action": {
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"storage"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://dramadunyam.com/*",
|
||||
"*://*.dramadunyam.com/*"
|
||||
],
|
||||
"js": ["inject.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://dramadunyam.com/*",
|
||||
"*://*.dramadunyam.com/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_end"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { width: 200px; padding: 12px; font-family: sans-serif; text-align: center; background: #f9f9f9; }
|
||||
.status { color: #27ae60; font-weight: bold; font-size: 14px; margin-bottom: 5px; }
|
||||
.desc { font-size: 12px; color: #666; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status">✓ Darama 차단기 작동 중</div>
|
||||
<p class="desc">dramadunyam.com의 새 탭 및 오버레이 광고를 방어합니다.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"manifest_version": 3,
|
||||
"version": "1.0",
|
||||
"name": "Darama AdBlocker",
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -13,9 +13,18 @@
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"<all_urls>",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
"declarativeNetRequest"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"declarative_net_request": {
|
||||
"rule_resources": [
|
||||
{
|
||||
"id": "block_redirects",
|
||||
"enabled": true,
|
||||
"path": "rules.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,70 @@
|
||||
// 1. 페이지 로드 시 정적 광고 및 불필요한 요소 즉시 제거
|
||||
// 1. 투명 링크(a 태그)를 통한 새 탭 열기 완벽 차단
|
||||
document.addEventListener('click', function(event) {
|
||||
const aTag = event.target.closest('a');
|
||||
|
||||
if (aTag) {
|
||||
const href = aTag.href || "";
|
||||
const currentDomain = location.hostname.replace("www.", "");
|
||||
|
||||
if (aTag.target === '_blank' && href && !href.includes(currentDomain) && !href.startsWith("/")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log("투명 a 태그 외부 광고 클릭 차단됨:", href);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
|
||||
// 2. 자바스크립트(window.open)를 통한 팝업/새 탭 열기 강제 무력화
|
||||
const script = document.createElement('script');
|
||||
script.textContent = `
|
||||
window.open = function() {
|
||||
console.log("window.open을 통한 새 탭/팝업 열기 차단됨");
|
||||
return null;
|
||||
};
|
||||
`;
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
script.remove();
|
||||
|
||||
|
||||
// 3. 투명 오버레이 덮개 광고 강제 삭제
|
||||
function removeInvisibleOverlays() {
|
||||
const elements = document.querySelectorAll('iframe, div, a');
|
||||
elements.forEach(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (
|
||||
style.zIndex >= 999 &&
|
||||
(style.position === 'fixed' || style.position === 'absolute') &&
|
||||
(style.width === '100%' || parseInt(style.width) >= window.innerWidth * 0.9)
|
||||
) {
|
||||
console.log("화면 덮개형 광고 요소 강제 삭제됨:", el);
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 4. 공통 광고 요소 및 오버레이 제거 실행 함수
|
||||
function handleCommon(parentElement) {
|
||||
if (!parentElement.querySelectorAll) return;
|
||||
removeInvisibleOverlays();
|
||||
}
|
||||
|
||||
|
||||
// 5. 페이지 로드 시 초기 정리 작업 실행
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
handleCommon(document);
|
||||
// applyDomainRules();
|
||||
});
|
||||
|
||||
// 2. DOM 변화를 실시간 감시하여 동적 아이프레임 및 광고 추가 완벽 차단
|
||||
|
||||
// 6. DOM 변화를 감시하여 동적으로 추가되는 아이프레임 광고 차단
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
// [darama 도메인] 동적으로 삽입되는 iframe 즉시 삭제
|
||||
if (location.href.includes("darama")) {
|
||||
// dramadunyam 도메인에서 동적으로 삽입되는 iframe 즉시 삭제
|
||||
if (location.href.includes("dramadunyam")) {
|
||||
if (node.tagName === 'IFRAME') {
|
||||
node.remove();
|
||||
} else if (node.querySelectorAll) {
|
||||
@@ -19,7 +72,6 @@ const observer = new MutationObserver((mutations) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 동적으로 삽입되는 공통 광고 노드 지속 삭제
|
||||
handleCommon(node);
|
||||
});
|
||||
});
|
||||
@@ -29,50 +81,3 @@ observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// 3. 공통 광고 요소 제거 함수 (정적/동적 DOM 모두 대응)
|
||||
function handleCommon(parentElement) {
|
||||
if (!parentElement.querySelectorAll) return;
|
||||
|
||||
// const adSelectors = [
|
||||
// ".top_google_ad_space", ".adv-group", "[id^='div-gpt-ad']",
|
||||
// "[id^='div_adnmore_area']", "[class^='adv-groupno']", ".code-block",
|
||||
// ".ad-template", "#xpromo-bottom-sheet", "[data-banner^='coupang-']",
|
||||
// "[id^='mobonDivBanner']", "[id^='pnlTopAd']", "[class^='ad-tag']",
|
||||
// "[class^='science-banner-area']"
|
||||
// ];
|
||||
//
|
||||
// adSelectors.forEach(selector => {
|
||||
// parentElement.querySelectorAll(selector).forEach(e => e.remove());
|
||||
// });
|
||||
|
||||
// parentElement.querySelectorAll('iframe').forEach(e => {
|
||||
// const src = e.getAttribute("src");
|
||||
// if (src && (src.includes("ads") || src.includes("coupang"))) {
|
||||
// e.remove();
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
// 4. 도메인별 맞춤형 요소 제거 (기존 룰 단순화)
|
||||
function applyDomainRules() {
|
||||
const url = location.href;
|
||||
|
||||
if (url.includes("nate.com")) {
|
||||
document.querySelectorAll('[class^="float-adv-wrap"], [id^="ad_"], #ad_sponsorBar, #newsSidebar, iframe[id^="ad"]').forEach(e => e.remove());
|
||||
} else if (url.includes("zdnet.co.kr")) {
|
||||
document.querySelectorAll('div[id*="FloatCon"]').forEach(e => e.remove());
|
||||
} else if (url.includes("inews24")) {
|
||||
document.querySelectorAll('ad[alt^="a"], div[class^="topnews"], form[class^="talkplus"], ins, footer').forEach(e => e.remove());
|
||||
} else if (url.includes("starnewskorea")) {
|
||||
document.querySelectorAll('div[class^="floating-top-ad"], div[id^="dablewidget_"], footer').forEach(e => e.remove());
|
||||
} else if (url.includes("khan.co.kr")) {
|
||||
document.querySelectorAll('[class^="banner-"], [class^="google-auto-placed"], footer, iframe').forEach(e => e.remove());
|
||||
} else if (url.includes("dcinside.com")) {
|
||||
document.querySelectorAll('[id^="view_btn_area"], [class^="trend-rank"], [class^="adv-group"], [id^="div_adnmore_area"]').forEach(e => e.remove());
|
||||
} else if (url.includes("fmkorea.com")) {
|
||||
document.querySelectorAll('[class^="fmad_wrapper"], [class^="ad ad_wrapper"], [class^="m_top_hotdeal"]').forEach(e => e.remove());
|
||||
} else if (url.includes("theqoo.net")) {
|
||||
document.querySelectorAll('[class^="board_content_google_ad"], ins[class^="adsbygoogle"]').forEach(e => e.remove());
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "block"
|
||||
},
|
||||
"condition": {
|
||||
"urlFilter": "*",
|
||||
"excludedUrlFilter": "*dramadunyam*",
|
||||
"initiatorDomains": ["dramadunyam.com"],
|
||||
"resourceTypes": ["main_frame"]
|
||||
}
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import bums.lunatic.launcher.BuildConfig
|
||||
import bums.lunatic.launcher.R
|
||||
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
|
||||
import bums.lunatic.launcher.home.NeoRssActivity
|
||||
import bums.lunatic.launcher.home.WebReaderActivity
|
||||
import bums.lunatic.launcher.model.AppInfo
|
||||
import bums.lunatic.launcher.model.SimpleContact
|
||||
@@ -39,7 +38,9 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import io.realm.kotlin.ext.query
|
||||
import io.realm.kotlin.query.Sort
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
@@ -51,8 +52,16 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
private var appsAdapter: AppsAdapter? = null
|
||||
private var contactAdapter: ContactAdapter? = null
|
||||
private var recAdapter: RecommendedAppsAdapter? = null
|
||||
|
||||
private val packageList = mutableListOf<AppInfo>()
|
||||
private val contactList = arrayListOf<SimpleContact>() // SimpleContact 클래스가 import 되어야 함
|
||||
private val contactList = arrayListOf<SimpleContact>()
|
||||
|
||||
private var currentScope: String = CategoryGrouper.SCOPE_ALL
|
||||
private var currentCategoryKey: String = "ALL"
|
||||
|
||||
private var fetchJob: Job? = null
|
||||
private var isSpinnerInitialized = false
|
||||
|
||||
companion object {
|
||||
const val TAG = "AppDrawerBottomSheet"
|
||||
@@ -62,19 +71,15 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
// 배경을 투명하게 하거나 스타일을 적용하기 위해
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// res/values/themes.xml 등에 BottomSheet 스타일 정의 필요 (모서리 둥글게 등)
|
||||
setStyle(STYLE_NORMAL, R.style.CustomBottomSheetDialogTheme)
|
||||
}
|
||||
|
||||
// 키보드가 올라올 때 바텀시트가 가려지지 않도록 설정
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
// 바텀시트가 완전히 펼쳐진 상태로 시작하게 설정
|
||||
dialog.setOnShowListener { dialogInterface ->
|
||||
val bottomSheetDialog = dialogInterface as BottomSheetDialog
|
||||
val bottomSheet = bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
@@ -94,80 +99,67 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
||||
fun showReader() {
|
||||
startActivity(Intent(context, WebReaderActivity::class.java))
|
||||
}
|
||||
|
||||
|
||||
private var currentScope: String = CategoryGrouper.SCOPE_ALL
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
isSpinnerInitialized = false
|
||||
setupAdapters()
|
||||
setupSearchButtons()
|
||||
setupListeners()
|
||||
setupKeyboardObserver()
|
||||
// 초기 데이터 로드
|
||||
|
||||
|
||||
// 초기 데이터 1회 로드 및 스피너 초기화
|
||||
fetchApps()
|
||||
setupCategorySpinner()
|
||||
}
|
||||
private var currentCategoryKey: String = "ALL"
|
||||
|
||||
private fun setupCategorySpinner() {
|
||||
// 1. 스피너에 들어갈 데이터(표시 이름들) 준비
|
||||
val displayList = CategoryManualMapper.CATEGORY_MAP.keys.toList()
|
||||
|
||||
// 2. 어댑터 설정 (기본 안드로이드 레이아웃 사용)
|
||||
val adapter = object : ArrayAdapter<String>(
|
||||
requireContext(),
|
||||
R.layout.spinner_item_dark,
|
||||
displayList
|
||||
){
|
||||
) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
|
||||
view.setBackgroundColor(Color.parseColor("#000000"))
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getDropDownView(position, convertView, parent)
|
||||
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
|
||||
view.setBackgroundColor(Color.parseColor("#000000"))
|
||||
return view
|
||||
}
|
||||
}
|
||||
adapter.setDropDownViewResource(R.layout.spinner_item_dark)
|
||||
binding.categorySpinner.adapter = adapter
|
||||
|
||||
// 3. 선택 리스너 설정
|
||||
binding.categorySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
// 스피너 어댑터 연결 직후 발생하는 최초 자동 호출을 건너뛰어 중복 로딩 차단
|
||||
if (!isSpinnerInitialized) {
|
||||
isSpinnerInitialized = true
|
||||
return
|
||||
}
|
||||
val selectedDisplayName = displayList[position]
|
||||
currentCategoryKey = CategoryManualMapper.CATEGORY_MAP[selectedDisplayName] ?: "ALL"
|
||||
|
||||
// 선택 변경 시 목록 갱신 (검색어 유지)
|
||||
fetchApps(binding.searchInput.text.toString())
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupKeyboardObserver() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
|
||||
// 키보드(IME)가 보이는지 확인
|
||||
val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
|
||||
if (isKeyboardVisible) {
|
||||
binding.quickSearch.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.quickSearch.visibility = View.GONE
|
||||
}
|
||||
|
||||
// 키보드 높이만큼 패딩 처리 등을 자동으로 하게 둠
|
||||
binding.quickSearch.visibility = if (isKeyboardVisible) View.VISIBLE else View.GONE
|
||||
insets
|
||||
}
|
||||
}
|
||||
@@ -175,48 +167,41 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
val dialog = dialog as? com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
val dialog = dialog as? BottomSheetDialog
|
||||
dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet ->
|
||||
|
||||
// 1. 동작 설정 (완전히 펼치기)
|
||||
val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bottomSheet)
|
||||
behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
|
||||
val behavior = BottomSheetBehavior.from(bottomSheet)
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
behavior.skipCollapsed = true
|
||||
|
||||
// 2. [핵심] 높이 강제 설정 (이게 없으면 리스트가 0dp일 때 안 보임)
|
||||
val layoutParams = bottomSheet.layoutParams
|
||||
layoutParams.height = android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
bottomSheet.layoutParams = layoutParams
|
||||
}
|
||||
}
|
||||
private var recAdapter: RecommendedAppsAdapter? = null
|
||||
|
||||
private fun setupAdapters() {
|
||||
// 기존 Activity의 packageManager 대신 requireContext().packageManager 사용
|
||||
val pm = requireContext().packageManager
|
||||
appsAdapter = AppsAdapter(pm, childFragmentManager, binding.appsCount)
|
||||
contactAdapter = ContactAdapter(requireContext(), childFragmentManager)
|
||||
|
||||
// 가로 그리드 개수 4~5개 정도로 조정 (기존 2개였으면 그대로 유지)
|
||||
binding.appsList.layoutManager = GridLayoutManager(context, 3,GridLayoutManager.HORIZONTAL,false)
|
||||
binding.appsList.layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false)
|
||||
binding.appsList.adapter = appsAdapter
|
||||
binding.appsList.setItemViewCacheSize(20)
|
||||
binding.appsList.setHasFixedSize(true) //
|
||||
binding.appsList.setHasFixedSize(true)
|
||||
|
||||
binding.contactList.layoutManager = GridLayoutManager(context, 2,GridLayoutManager.HORIZONTAL,false)
|
||||
binding.contactList.layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
|
||||
binding.contactList.adapter = contactAdapter
|
||||
|
||||
recAdapter = RecommendedAppsAdapter(requireContext(),pm,childFragmentManager)
|
||||
binding.recAppsList.layoutManager = GridLayoutManager(context, 2,GridLayoutManager.HORIZONTAL,false)
|
||||
recAdapter = RecommendedAppsAdapter(requireContext(), pm, childFragmentManager)
|
||||
binding.recAppsList.layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
|
||||
binding.recAppsList.adapter = recAdapter
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// 검색어 입력 리스너
|
||||
binding.searchInput.doOnTextChanged { inputText, _, _, _ ->
|
||||
filterAppsList(inputText.toString())
|
||||
}
|
||||
|
||||
// 엔터키 입력 시 검색 실행
|
||||
binding.searchInput.setOnKeyListener { _, keyCode, event ->
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
|
||||
checkResult(binding.searchInput.text.toString())
|
||||
@@ -226,20 +211,16 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
// 새로고침 버튼
|
||||
binding.reset.setOnClickListener {
|
||||
binding.searchInput.text?.clear()
|
||||
fetchApps()
|
||||
}
|
||||
}
|
||||
|
||||
val keyword : String
|
||||
get() {
|
||||
return binding.searchInput.text.toString()
|
||||
}
|
||||
val keyword: String
|
||||
get() = binding.searchInput.text.toString()
|
||||
|
||||
private fun setupSearchButtons() {
|
||||
// 기존 AppDrawer의 버튼 연결
|
||||
binding.searchNmap.setOnClickListener {
|
||||
openSearchApps("nmap://search?query=${keyword}&appname=${BuildConfig.APPLICATION_ID}", "com.nhn.android.nmap")
|
||||
}
|
||||
@@ -249,58 +230,44 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
binding.hidden.setOnClickListener {
|
||||
binding.hidden.isSelected = !binding.hidden.isSelected
|
||||
filterAppsList(keyword)
|
||||
|
||||
}
|
||||
binding.searchReader.setOnClickListener {
|
||||
showReader()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
|
||||
binding.searchTaxi.setOnClickListener {
|
||||
//// val destName = Uri.encode("서울역")
|
||||
//// val url = "kakaot://taxi?dest_lat=${URLEncoder.encode("37.467696")}&dest_lng=${URLEncoder.encode("127.101063")}&dest_name=세곡동557"
|
||||
// val url = "kakaomap://search?q=세곡동 557"
|
||||
// val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
// startActivity(intent)
|
||||
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696")}&goaly=${URLEncoder.encode("127.101063")}","com.kakao.taxi")
|
||||
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696", "UTF-8")}&goaly=${URLEncoder.encode("127.101063", "UTF-8")}", "com.kakao.taxi")
|
||||
}
|
||||
binding.searchMusic.setOnClickListener {
|
||||
try {
|
||||
val intent: Intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH)
|
||||
|
||||
intent.putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
|
||||
intent.putExtra(SearchManager.QUERY, keyword) // 검색하고자 하는 곡명이나 아티스트명
|
||||
|
||||
intent.setPackage("com.apple.android.music") // 애플 뮤직 패키지 명시
|
||||
|
||||
if (intent.resolveActivity(requireContext().getPackageManager()) != null) {
|
||||
val intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply {
|
||||
putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
|
||||
putExtra(SearchManager.QUERY, keyword)
|
||||
setPackage("com.apple.android.music")
|
||||
}
|
||||
if (intent.resolveActivity(requireContext().packageManager) != null) {
|
||||
startActivity(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 앱이 없을 경우 플레이스토어로 이동
|
||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music"))
|
||||
marketIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music")).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
requireContext().startActivity(marketIntent)
|
||||
}
|
||||
}
|
||||
|
||||
binding.searchTmap.setOnClickListener {
|
||||
try {
|
||||
// 한글 검색어를 위해 URL 인코딩 필수
|
||||
val encodedKeyword = URLEncoder.encode(keyword, "UTF-8")
|
||||
val uri = Uri.parse("tmap://search?name=$encodedKeyword")
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
requireContext().startActivity(intent)
|
||||
requireContext().startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
} catch (e: Exception) {
|
||||
// 앱이 없을 경우 스토어로 이동
|
||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.skt.tmap.ku"))
|
||||
requireContext().startActivity(marketIntent)
|
||||
}
|
||||
}
|
||||
binding.searchGoogle.setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_WEB_SEARCH).apply {
|
||||
putExtra(SearchManager.QUERY, keyword) // 질문 전달
|
||||
putExtra(SearchManager.QUERY, keyword)
|
||||
}
|
||||
if (intent.resolveActivity(requireContext().packageManager) != null) {
|
||||
startActivity(intent)
|
||||
@@ -309,10 +276,8 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
binding.searchNaver.setOnClickListener {
|
||||
openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun openSearchApps(schemeString: String, packageName: String? = null) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(schemeString))
|
||||
@@ -321,31 +286,24 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
WorkersDb.logAppUsage(packageName, UsageLogType.APP, UsageUpdateType.DATETIME)
|
||||
}
|
||||
startActivity(intent)
|
||||
dismiss() // 실행 후 닫기
|
||||
dismiss()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// 앱이 없을 경우 웹으로 열기 등의 예외처리 권장
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkResult(keyword: String) {
|
||||
// 1. 현재 필터링된 리스트에서 첫 번째 아이템들을 가져옴
|
||||
val filteredApp = appsAdapter?.oldList?.firstOrNull()
|
||||
val filteredContact = contactAdapter?.oldList?.firstOrNull()
|
||||
|
||||
val appCount = appsAdapter?.oldList?.size ?: 0
|
||||
val contactCount = contactAdapter?.oldList?.size ?: 0
|
||||
|
||||
// Case 1: 앱만 하나 검색된 경우
|
||||
if (appCount == 1 && contactCount != 1) {
|
||||
launchApp(filteredApp)
|
||||
}
|
||||
// Case 2: 연락처만 하나 검색된 경우
|
||||
else if (contactCount == 1 && appCount != 1) {
|
||||
} else if (contactCount == 1 && appCount != 1) {
|
||||
launchContact(filteredContact)
|
||||
}
|
||||
// Case 3: 앱과 연락처가 동시에 하나씩 검색된 경우 (핵심 로직)
|
||||
else if (appCount == 1 && contactCount == 1) {
|
||||
} else if (appCount == 1 && contactCount == 1) {
|
||||
val appUsage = filteredApp?.clickCount ?: 0
|
||||
val contactUsage = filteredContact?.touchCount ?: 0
|
||||
if (contactUsage > appUsage) {
|
||||
@@ -353,29 +311,24 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
} else {
|
||||
launchApp(filteredApp)
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
openSearchApps("https://www.google.com/search?q=${keyword}", "com.android.chrome")
|
||||
// openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
|
||||
// dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsKorean(text: String): Boolean {
|
||||
return text.any { it.code in 0xAC00..0xD7AF }
|
||||
}
|
||||
private fun askGemini(keyword: String) {
|
||||
|
||||
private fun askGemini(keyword: String) {
|
||||
try {
|
||||
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
|
||||
|
||||
// 반대 언어 키워드 생성
|
||||
val converted = if (isKorean) {
|
||||
SimpleTransliterater.koToEn(keyword) // 한글 -> 영타
|
||||
SimpleTransliterater.koToEn(keyword)
|
||||
} else {
|
||||
SimpleTransliterater.enToKo(keyword) // 영어 -> 한타(자모나열)
|
||||
SimpleTransliterater.enToKo(keyword)
|
||||
}
|
||||
Blog.LOGE("keyword >>>> ${keyword} converted >>> ${converted}")
|
||||
// 제미나이는 'ㅇㅏㄴㄴㅕㅇ' 같은 자모 나열도 맥락상 '안녕'으로 매우 잘 이해합니다.
|
||||
val combinedQuery = if (converted != keyword) {
|
||||
"$keyword ($converted) 에 대해 알려줘"
|
||||
} else {
|
||||
@@ -389,29 +342,22 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// 실패 시 일반 구글 검색으로 폴백
|
||||
openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 가독성을 위한 헬퍼 함수: 앱 실행
|
||||
private fun launchApp(app: AppInfo?) {
|
||||
app?.pkgName?.let { pkg ->
|
||||
// 사용 기록 로깅
|
||||
WorkersDb.logAppUsage(pkg, UsageLogType.APP, UsageUpdateType.DATETIME)
|
||||
// 앱 실행
|
||||
val intent = requireContext().packageManager.getLaunchIntentForPackage(pkg)
|
||||
intent?.let { startActivity(it) }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// 코드 가독성을 위한 헬퍼 함수: 연락처 다이얼러 실행
|
||||
private fun launchContact(contact: SimpleContact?) {
|
||||
contact?.let { c ->
|
||||
// 사용 기록 로깅
|
||||
c.id?.let { id -> WorkersDb.logAppUsage(id, UsageLogType.CONTACT, UsageUpdateType.DATETIME) }
|
||||
// 전화 걸기 화면 실행
|
||||
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${c.phoneNumber}"))
|
||||
startActivity(intent)
|
||||
dismiss()
|
||||
@@ -422,36 +368,41 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
fetchApps(searchString)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 앱 목록을 불러오는 함수
|
||||
* - 검색어(keyword)가 있으면 필터링을 수행합니다.
|
||||
* - 검색어가 없으면 '문맥 기반 추천 앱'을 최상단에 배치합니다.
|
||||
* - IO 스레드에서 실행하여 UI 끊김을 방지합니다.
|
||||
* 앱 및 연락처 목록을 비동기로 불러오는 함수
|
||||
* - 이전 작업 취소(fetchJob?.cancel)로 연속 입력 시 병목 방지
|
||||
* - queryIntentActivities 단 1회 호출로 런처 실행 가능 패키지 Set 캐싱
|
||||
* - 추천 목록 준비 즉시 UI 선반영
|
||||
*/
|
||||
private fun fetchApps(keyword: String? = null) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
fetchJob?.cancel()
|
||||
fetchJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = context ?: return@launch
|
||||
val realm = WorkersDb.getRealm()
|
||||
val pm = requireContext().packageManager
|
||||
val pm = context.packageManager
|
||||
|
||||
// 1. [추천 로직] 에러가 나도 앱 목록 로딩은 진행되도록 try-catch 분리
|
||||
// 1. 런처에서 실행 가능한 전체 패키지 Set 확보 (단 1회 IPC)
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
}
|
||||
val launchablePackages = pm.queryIntentActivities(mainIntent, 0)
|
||||
.mapNotNull { it.activityInfo?.packageName }
|
||||
.toHashSet()
|
||||
|
||||
// 2. 추천 목록 먼저 계산 후 UI 선반영
|
||||
val scoredItems = WorkersDb.getContextualRecommendations(limit = 18)
|
||||
|
||||
val unifiedList = mutableListOf<RecommendationItem>()
|
||||
|
||||
for (item in scoredItems) {
|
||||
if (item.type == "APP") {
|
||||
val app = realm.query<AppInfo>("pkgName == $0", item.key).first().find()
|
||||
|
||||
if (app != null && (app.blockRecommend == false || (binding.hidden.isSelected == app.blockRecommend))) {
|
||||
try { if(pm.getLaunchIntentForPackage(app.pkgName ?: "") != null) {
|
||||
if (launchablePackages.contains(app.pkgName)) {
|
||||
unifiedList.add(RecommendationItem.AppItem(realm.copyFromRealm(app)))
|
||||
}} catch (e: Exception) { }
|
||||
}
|
||||
}
|
||||
} else if (item.type == "CONTACT") {
|
||||
// 연락처 ID나 전화번호로 조회 (Log 저장 시 key가 무엇인지에 따라 다름)
|
||||
val contact = realm.query<SimpleContact>("id == $0", item.key).first().find()
|
||||
if (contact != null) {
|
||||
unifiedList.add(RecommendationItem.ContactItem(realm.copyFromRealm(contact)))
|
||||
@@ -459,149 +410,95 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// =========================================================
|
||||
// 2. [앱 검색 로직 변경] DB 쿼리 -> 메모리 필터링
|
||||
// =========================================================
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding != null && unifiedList.isNotEmpty()) {
|
||||
binding.recAppsList.visibility = View.VISIBLE
|
||||
recAdapter?.submitList(unifiedList)
|
||||
}
|
||||
}
|
||||
|
||||
// 2-1. 일단 모든 앱을 가져옵니다. (기본 정렬: 클릭수, 최근사용순)
|
||||
// 쿼리 조건 없이 모두 가져옴
|
||||
// 3. 전체 앱 목록 쿼리 및 메모리 필터링
|
||||
val baseAppQuery = realm.query<AppInfo>()
|
||||
.sort("clickCount", Sort.DESCENDING)
|
||||
.sort("lastUseDate", Sort.DESCENDING)
|
||||
.find()
|
||||
|
||||
val scopedAppsList = baseAppQuery.map {
|
||||
val appCopy = realm.copyFromRealm(it)
|
||||
val scopedAppsList = baseAppQuery.mapNotNull { app ->
|
||||
val pkg = app.pkgName ?: ""
|
||||
if (!launchablePackages.contains(pkg)) return@mapNotNull null
|
||||
|
||||
// 💡 수동 매퍼를 사용하여 카테고리 강제 재할당
|
||||
// it.systemCategoryInt는 AppInfo 모델에 시스템 카테고리 값이 저장되어 있다고 가정함
|
||||
val fixedCategory = CategoryManualMapper.getFixedCategory(it.pkgName ?:"", it.appName, it.systemCategoryInt)
|
||||
appCopy.category = fixedCategory
|
||||
appCopy
|
||||
}
|
||||
.filter { appInfo ->
|
||||
val isLaunchable = try {
|
||||
pm.getLaunchIntentForPackage(appInfo.pkgName ?: "") != null
|
||||
} catch (e: Exception) { false }
|
||||
val isVisible = binding.hidden.isSelected || (app.visibilityMode == 0)
|
||||
if (!isVisible) return@mapNotNull null
|
||||
|
||||
val isVisible = binding.hidden.isSelected || (appInfo.visibilityMode == 0)
|
||||
val fixedCategory = CategoryManualMapper.getFixedCategory(pkg, app.appName, app.systemCategoryInt)
|
||||
if (currentCategoryKey != "ALL" && fixedCategory != currentCategoryKey) return@mapNotNull null
|
||||
|
||||
// [핵심 변경] 단순 문자열 비교 필터링
|
||||
val isCategoryMatch = if (currentCategoryKey == "ALL") {
|
||||
true
|
||||
} else {
|
||||
appInfo.category == currentCategoryKey
|
||||
realm.copyFromRealm(app).apply { category = fixedCategory }
|
||||
}
|
||||
|
||||
isLaunchable && isVisible && isCategoryMatch
|
||||
}
|
||||
|
||||
// 2-2. Realm 객체를 메모리 객체로 변환 (속도 향상 및 스레드 안전)
|
||||
// 여기서 visible 필터링 등을 미리 해도 좋습니다.
|
||||
|
||||
|
||||
// 2-3. 키워드에 따라 필터링 및 정렬 (유사도 적용)
|
||||
val filteredApps = if (keyword.isNullOrEmpty()) {
|
||||
// 키워드가 없으면 기본 정렬 그대로 사용
|
||||
scopedAppsList
|
||||
} else {
|
||||
// 검색어 전처리 (한/영 변환 등)
|
||||
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
|
||||
val converted = if (isKorean) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
|
||||
// 검색어 후보군 (예: "카카오", "zkzk")
|
||||
val searchTerms = listOfNotNull(keyword, converted).distinct()
|
||||
|
||||
// [핵심] 각 앱에 대해 점수 계산
|
||||
scopedAppsList.mapNotNull { app ->
|
||||
// 1. 검색 대상 필드들을 리스트로 묶습니다. (null인 필드는 자동으로 제외됨)
|
||||
val targetFields = listOfNotNull(
|
||||
app.appName, // 앱 이름
|
||||
app.pkgName, // 패키지명 (예: com.kakao.talk)
|
||||
app.category // 카테고리 (예: Social, Game)
|
||||
)
|
||||
|
||||
// 2. 여러 검색어(term) 중 가장 높은 점수를 계산
|
||||
val targetFields = listOfNotNull(app.appName, app.pkgName, app.category)
|
||||
val maxScore = searchTerms.maxOf { term ->
|
||||
|
||||
// [1단계] 서치 인덱스 확인 (가장 빠름)
|
||||
// 만약 makeSearchIndex에 패키지명 등을 이미 포함시켰다면 여기서 바로 잡힙니다.
|
||||
if (app.searchIndex.contains(term, true)) return@maxOf 1.0
|
||||
targetFields.maxOf { field ->
|
||||
if (SimpleTransliterater.match(field, term)) 1.0
|
||||
else SimpleTransliterater.getSimilarity(field, term)
|
||||
}
|
||||
}
|
||||
if (maxScore > 0.3) Pair(app, maxScore) else null
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
}
|
||||
|
||||
// [2단계] 각 필드(이름, 패키지, 카테고리)에 대해 점수 계산 후 가장 높은 것 선택
|
||||
// targetFields 리스트를 순회하며 가장 높은 매칭 점수를 찾습니다.
|
||||
val fieldMaxScore = targetFields.maxOf { field ->
|
||||
// A. 단순 포함 or 초성 매칭 (100점)
|
||||
if (SimpleTransliterater.match(field, term)) {
|
||||
1.0
|
||||
// 4. 연락처 목록 필터링 (검색어 없을 때는 상위 10건만 DB 조회)
|
||||
val filteredContacts: List<SimpleContact> = if (keyword.isNullOrEmpty()) {
|
||||
realm.query<SimpleContact>()
|
||||
.sort("touchCount", Sort.DESCENDING)
|
||||
.limit(10)
|
||||
.find()
|
||||
.map { realm.copyFromRealm(it) }
|
||||
} else {
|
||||
// B. 유사도 계산 (0.0 ~ 1.0점)
|
||||
SimpleTransliterater.getSimilarity(field, term)
|
||||
}
|
||||
}
|
||||
|
||||
fieldMaxScore // 해당 검색어(term)에 대한 최종 점수 반환
|
||||
}
|
||||
|
||||
// 3. 유사도 임계값 체크 (0.3점 이상만 통과)
|
||||
if (maxScore > 0.3) {
|
||||
Pair(app, maxScore)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.sortedByDescending { it.second } // 점수 높은 순 정렬
|
||||
.map { it.first } // 결과 반환// 앱 정보만 추출
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 3. [연락처 검색 로직 변경] 동일한 방식 적용
|
||||
// =========================================================
|
||||
|
||||
var filteredContacts: List<SimpleContact> = emptyList()
|
||||
val allContacts = realm.query<SimpleContact>()
|
||||
.sort("touchCount", Sort.DESCENDING)
|
||||
.find()
|
||||
.map { realm.copyFromRealm(it) } // 메모리로 복사
|
||||
if (!keyword.isNullOrEmpty()) {
|
||||
// 연락처도 전체 로드 (보통 수천 개 단위까지는 문제 없음)
|
||||
val searchTerms = listOfNotNull(keyword, if (keyword.any { it.code in 0xAC00..0xD7AF }) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)).distinct()
|
||||
.map { realm.copyFromRealm(it) }
|
||||
|
||||
filteredContacts = allContacts.mapNotNull { contact ->
|
||||
val searchTerms = listOfNotNull(
|
||||
keyword,
|
||||
if (keyword.any { it.code in 0xAC00..0xD7AF }) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
|
||||
).distinct()
|
||||
|
||||
allContacts.mapNotNull { contact ->
|
||||
val maxScore = searchTerms.maxOf { term ->
|
||||
// 연락처 이름이나 번호 매칭 확인
|
||||
if (SimpleTransliterater.match(contact.searchIndex ?: "", term)) return@maxOf 1.0
|
||||
if (SimpleTransliterater.match(contact.name ?: "", term)) return@maxOf 1.0
|
||||
if (contact.phoneNumber?.contains(term) == true) return@maxOf 1.0
|
||||
|
||||
// 유사도 확인
|
||||
SimpleTransliterater.getSimilarity(contact.name ?: "", term)
|
||||
}
|
||||
|
||||
if (maxScore > 0.4) Pair(contact, maxScore) else null
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.take(10) // 상위 10개만
|
||||
.take(10)
|
||||
.map { it.first }
|
||||
} else {
|
||||
filteredContacts = allContacts.take(10)
|
||||
}
|
||||
|
||||
|
||||
// 4. [UI 업데이트] (기존 로직 유지)
|
||||
// 5. 앱 및 연락처 UI 갱신
|
||||
withContext(Dispatchers.Main) {
|
||||
if (unifiedList.isNotEmpty()) {
|
||||
binding.recAppsList.visibility = View.VISIBLE
|
||||
recAdapter?.submitList(unifiedList)
|
||||
}
|
||||
if (_binding == null) return@withContext
|
||||
|
||||
// 앱 리스트 갱신
|
||||
packageList.clear()
|
||||
packageList.addAll(filteredApps)
|
||||
appsAdapter?.updateData(packageList)
|
||||
binding.appsCount.text = "${packageList.size} Apps"
|
||||
|
||||
// 연락처 리스트 갱신
|
||||
if (filteredContacts.isNotEmpty()) {
|
||||
binding.contactList.visibility = View.VISIBLE
|
||||
contactAdapter?.updateData(filteredContacts)
|
||||
@@ -610,13 +507,11 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
contactAdapter?.updateData(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
// 새 검색어 입력 등으로 인한 정상 취소
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +525,7 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
fetchJob?.cancel()
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
@@ -3,13 +3,18 @@ package bums.lunatic.launcher.home
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.text.Editable
|
||||
import android.text.TextUtils
|
||||
import android.text.TextWatcher
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -19,6 +24,8 @@ import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
@@ -34,22 +41,27 @@ import bums.lunatic.launcher.player.DocumentViewerActivity
|
||||
import bums.lunatic.launcher.player.ImageViewerActivity
|
||||
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||
import com.google.mlkit.nl.translate.Translation
|
||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Locale
|
||||
|
||||
enum class FileFilterType(val label: String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
|
||||
enum class FileSortType(val label: String) { LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), NAME("파일명"), DOWNLOAD_DATE("다운로드"), SIZE("용량") }
|
||||
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
|
||||
|
||||
// 💡 자막 번역 데이터를 담을 데이터 클래스 추가
|
||||
data class SubtitleBlock(
|
||||
val startSec: Double,
|
||||
val endSec: Double,
|
||||
@@ -57,6 +69,12 @@ data class SubtitleBlock(
|
||||
var translatedText: String? = null
|
||||
)
|
||||
|
||||
data class VideoFrameItem(
|
||||
val timestampMs: Long,
|
||||
val timeLabel: String,
|
||||
val bitmap: Bitmap
|
||||
)
|
||||
|
||||
class NaturalOrderComparator : Comparator<File> {
|
||||
override fun compare(f1: File, f2: File): Int {
|
||||
val splitRegex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
|
||||
@@ -97,6 +115,7 @@ enum class RenameMode(val label: String) {
|
||||
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")
|
||||
private const val VLC_PACKAGE_NAME = "org.videolan.vlc"
|
||||
|
||||
class CompletedFilesActivity : AppCompatActivity() {
|
||||
|
||||
@@ -115,7 +134,6 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
private val selectedFiles = mutableSetOf<File>()
|
||||
private lateinit var backPressedCallback: OnBackPressedCallback
|
||||
|
||||
// 외부 재생기 복귀 시 리스트 갱신을 위한 Launcher
|
||||
private val videoPlayerLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
@@ -126,6 +144,13 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
private lateinit var geckoWeb: GeckoWeb
|
||||
private var targetVideoForSubtitle: File? = null
|
||||
|
||||
private val subtitleSearchQueue = ArrayDeque<File>()
|
||||
private var totalQueueCount = 0
|
||||
private var completedQueueCount = 0
|
||||
private var isBatchSubtitleMode = false
|
||||
|
||||
private lateinit var videoPreviewHelper: VideoPreviewHelper
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.fragment_completed_files)
|
||||
@@ -137,6 +162,11 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
geckoWeb = findViewById(R.id.geckoWebFragment)
|
||||
geckoWeb.visibility = View.GONE
|
||||
|
||||
videoPreviewHelper = VideoPreviewHelper(this) { videoFile, timestampMs ->
|
||||
val subPath = findSubtitleFile(videoFile.absolutePath)
|
||||
launchVlcPlayer(videoFile, subPath, timestampMs)
|
||||
}
|
||||
|
||||
backPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (isSelectionMode) toggleSelectionMode(false) else navigateUp()
|
||||
@@ -152,11 +182,30 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
private fun toggleSelectionMode(enable: Boolean) {
|
||||
isSelectionMode = enable
|
||||
if (!enable) selectedFiles.clear()
|
||||
adapter.updateSelection(selectedFiles)
|
||||
updateSelectionUI()
|
||||
findViewById<View>(R.id.layoutSelectionActions)?.visibility = if (enable) View.VISIBLE else View.GONE
|
||||
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
||||
}
|
||||
|
||||
private fun updateSelectionUI() {
|
||||
adapter.updateSelection(selectedFiles)
|
||||
|
||||
// 1. 미리보기 버튼: 전체 선택이 딱 1개이고, 그 파일이 영상일 때만 노출
|
||||
val singleFile = selectedFiles.singleOrNull()
|
||||
val isSingleVideo = singleFile != null && singleFile.isFile && extVideos.contains(singleFile.extension.lowercase())
|
||||
findViewById<View>(R.id.btnVideoPreview)?.visibility = if (isSingleVideo) View.VISIBLE else View.GONE
|
||||
|
||||
// 2. 자막 검색 버튼: 영상 파일이 1개 이상 선택되었을 때 노출
|
||||
val videoCount = selectedFiles.count { it.isFile && extVideos.contains(it.extension.lowercase()) }
|
||||
val btnSub = findViewById<TextView>(R.id.btnSearchSubtitle)
|
||||
if (videoCount > 0) {
|
||||
btnSub?.visibility = View.VISIBLE
|
||||
btnSub?.text = if (videoCount == 1) "자막 검색" else "자막($videoCount)"
|
||||
} else {
|
||||
btnSub?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
recyclerView = findViewById(R.id.recyclerViewCompletedFiles)
|
||||
updateRecyclerViewLayoutManager()
|
||||
@@ -171,7 +220,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
if (isSelectionMode) {
|
||||
if (selectedFiles.contains(file)) selectedFiles.remove(file) else selectedFiles.add(file)
|
||||
if (selectedFiles.isEmpty()) toggleSelectionMode(false)
|
||||
else adapter.updateSelection(selectedFiles)
|
||||
else updateSelectionUI()
|
||||
} else {
|
||||
if (file.isDirectory) {
|
||||
currentDir = file
|
||||
@@ -181,7 +230,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
|
||||
if (extVideos.contains(file.extension.lowercase())) {
|
||||
val subtitlePath = findSubtitleFile(file.absolutePath)
|
||||
launchExternalPlayer(file, subtitlePath)
|
||||
launchVlcPlayer(file, subtitlePath, 0L)
|
||||
} else if (extImages.contains(file.extension.lowercase())) {
|
||||
trackFileAccess(file.name)
|
||||
loadFiles()
|
||||
@@ -210,7 +259,9 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
if (file.isDirectory && !isSelectionMode && !isProtectedFile(file)) {
|
||||
showFolderDeleteDialog(file)
|
||||
} else if (!isSelectionMode) {
|
||||
showFileContextMenu(file)
|
||||
toggleSelectionMode(true)
|
||||
selectedFiles.add(file)
|
||||
updateSelectionUI()
|
||||
}
|
||||
},
|
||||
getAccessCount = { fileName -> getAccessCount(fileName) }
|
||||
@@ -231,44 +282,71 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun showFileContextMenu(file: File) {
|
||||
val isVideo = extVideos.contains(file.extension.lowercase())
|
||||
val options = mutableListOf("선택 모드 시작")
|
||||
// --- 자막 검색 & 일괄 처리 큐 파이프라인 ---
|
||||
|
||||
if (isVideo) options.add("자막 검색 및 다운로드")
|
||||
private fun startSubtitleSearchQueue(videoFiles: List<File>) {
|
||||
if (videoFiles.isEmpty()) return
|
||||
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle(file.name)
|
||||
.setItems(options.toTypedArray()) { _, which ->
|
||||
when (options[which]) {
|
||||
"선택 모드 시작" -> {
|
||||
toggleSelectionMode(true)
|
||||
selectedFiles.add(file)
|
||||
adapter.updateSelection(selectedFiles)
|
||||
}
|
||||
"자막 검색 및 다운로드" -> {
|
||||
startSubtitleSearch(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
.show()
|
||||
subtitleSearchQueue.clear()
|
||||
val sortedVideos = videoFiles.sortedWith(NaturalOrderComparator())
|
||||
subtitleSearchQueue.addAll(sortedVideos)
|
||||
|
||||
totalQueueCount = sortedVideos.size
|
||||
completedQueueCount = 0
|
||||
isBatchSubtitleMode = totalQueueCount > 1
|
||||
|
||||
processNextSubtitleInQueue()
|
||||
}
|
||||
|
||||
private fun startSubtitleSearch(videoFile: File) {
|
||||
targetVideoForSubtitle = videoFile
|
||||
val query = videoFile.nameWithoutExtension
|
||||
private fun processNextSubtitleInQueue() {
|
||||
val nextVideo = subtitleSearchQueue.pollFirst()
|
||||
if (nextVideo == null) {
|
||||
targetVideoForSubtitle = null
|
||||
if (isBatchSubtitleMode) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"일괄 자막 작업 완료 (${completedQueueCount}/${totalQueueCount}개 처리됨)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
isBatchSubtitleMode = false
|
||||
return
|
||||
}
|
||||
|
||||
targetVideoForSubtitle = nextVideo
|
||||
val currentIndex = totalQueueCount - subtitleSearchQueue.size
|
||||
val query = nextVideo.nameWithoutExtension
|
||||
val searchUrl = "https://www.subtitlecat.com/index.php?search=${URLEncoder.encode(query, "UTF-8")}"
|
||||
|
||||
geckoWeb.loadUrl(searchUrl)
|
||||
Toast.makeText(this, "자막을 검색 중입니다...", Toast.LENGTH_SHORT).show()
|
||||
val progressPrefix = if (isBatchSubtitleMode) "[$currentIndex/$totalQueueCount] " else ""
|
||||
Toast.makeText(this, "${progressPrefix}'${nextVideo.name}' 자막 검색 중...", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun cancelSubtitleQueue() {
|
||||
subtitleSearchQueue.clear()
|
||||
targetVideoForSubtitle = null
|
||||
isBatchSubtitleMode = false
|
||||
Toast.makeText(this, "자막 검색 작업이 중단되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
fun showSubtitleSelectionDialog(subList: List<Map<String, String>>) {
|
||||
runOnUiThread {
|
||||
if (isDestroyed || isFinishing) return@runOnUiThread
|
||||
|
||||
val currentVideo = targetVideoForSubtitle
|
||||
val currentIndex = totalQueueCount - subtitleSearchQueue.size
|
||||
val dialogTitle = if (isBatchSubtitleMode && currentVideo != null) {
|
||||
"[$currentIndex/$totalQueueCount] ${currentVideo.name}"
|
||||
} else {
|
||||
currentVideo?.name ?: "자막 선택"
|
||||
}
|
||||
|
||||
if (subList.isEmpty()) {
|
||||
Toast.makeText(this, "검색된 자막이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(this, "'${currentVideo?.name}' 검색된 자막이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
if (isBatchSubtitleMode) {
|
||||
processNextSubtitleInQueue()
|
||||
}
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
@@ -276,8 +354,8 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
" [${item["lang"]}] ${item["title"]} (${item["size"]})"
|
||||
}.toTypedArray()
|
||||
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle("자막 선택")
|
||||
val builder = android.app.AlertDialog.Builder(this)
|
||||
.setTitle(dialogTitle)
|
||||
.setItems(items) { _, which ->
|
||||
val selected = subList[which]
|
||||
val detailUrl = selected["downloadUrl"]
|
||||
@@ -285,10 +363,26 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
if (detailUrl != null) {
|
||||
Toast.makeText(this, "자막을 다운로드합니다...", Toast.LENGTH_SHORT).show()
|
||||
geckoWeb.sendJsonMsg("GO_TO_SUBTITLE_DETAIL", "url" to detailUrl)
|
||||
} else if (isBatchSubtitleMode) {
|
||||
processNextSubtitleInQueue()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
|
||||
if (isBatchSubtitleMode) {
|
||||
builder.setNeutralButton("건너뛰기") { _, _ ->
|
||||
processNextSubtitleInQueue()
|
||||
}
|
||||
builder.setNegativeButton("전체 중단") { _, _ ->
|
||||
cancelSubtitleQueue()
|
||||
}
|
||||
builder.setCancelable(false)
|
||||
} else {
|
||||
builder.setNegativeButton("취소") { _, _ ->
|
||||
targetVideoForSubtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,21 +404,34 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@CompletedFilesActivity, "자막 파일 저장 완료! 언어를 분석합니다.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(this@CompletedFilesActivity, "자막 저장 완료! 언어를 분석합니다.", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
// 💡 즉시 재생하지 않고, 번역 프로세스 먼저 실행
|
||||
processAndTranslateSubtitle(newSubFile, videoFile)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@CompletedFilesActivity, "자막 다운로드 실패 (HTTP ${response.code})", Toast.LENGTH_SHORT).show()
|
||||
if (isBatchSubtitleMode) processNextSubtitleInQueue()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@CompletedFilesActivity, "자막 다운로드 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
if (isBatchSubtitleMode) processNextSubtitleInQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 💡 [번역 파이프라인 시작 (PlayerActivity에서 이식됨)] ---
|
||||
private fun onSingleSubtitlePipelineFinished(videoFile: File, finalSubPath: String) {
|
||||
completedQueueCount++
|
||||
if (isBatchSubtitleMode) {
|
||||
processNextSubtitleInQueue()
|
||||
} else {
|
||||
targetVideoForSubtitle = null
|
||||
launchVlcPlayer(videoFile, finalSubPath, 0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSrtTime(timeStr: String): Double {
|
||||
val cleanStr = timeStr.trim().replace(",", ".")
|
||||
@@ -351,7 +458,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
if (!file.exists()) return result
|
||||
|
||||
try {
|
||||
val content = readTextWithEncoding(file) // FileUtils 유틸 사용
|
||||
val content = readTextWithEncoding(file)
|
||||
val blocks = content.split("\n\n", "\r\n\r\n")
|
||||
|
||||
for (block in blocks) {
|
||||
@@ -378,7 +485,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
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}" // 번역본 + 원본 병기
|
||||
"${block.translatedText}\n${block.text}"
|
||||
} else {
|
||||
block.text
|
||||
}
|
||||
@@ -390,11 +497,10 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
private fun processAndTranslateSubtitle(subFile: File, videoFile: File) {
|
||||
val subtitles = parseSrt(subFile)
|
||||
if (subtitles.isEmpty()) {
|
||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
||||
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||
return
|
||||
}
|
||||
|
||||
// 언어 감지를 위해 자막 앞부분 샘플링
|
||||
val sampleText = subtitles.take(10).joinToString(" ") { it.text }
|
||||
val languageIdentifier = LanguageIdentification.getClient()
|
||||
|
||||
@@ -402,20 +508,20 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
.addOnSuccessListener { languageCode ->
|
||||
if (languageCode == "und" || languageCode == "ko") {
|
||||
Log.d("CompletedFilesActivity", "번역 불필요 (언어: $languageCode)")
|
||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
||||
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||
} else {
|
||||
Log.d("CompletedFilesActivity", "감지된 언어: $languageCode - 번역 시작")
|
||||
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
|
||||
if (sourceLang != null) {
|
||||
startTranslationProcess(sourceLang, subtitles, subFile, videoFile)
|
||||
} else {
|
||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
||||
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
Log.e("CompletedFilesActivity", "언어 감지 실패", it)
|
||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
||||
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +532,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
.build()
|
||||
|
||||
val translator = Translation.getClient(options)
|
||||
Toast.makeText(this, "자막을 한국어로 번역 중입니다...", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(this, "'${videoFile.name}' 자막 번역 중...", Toast.LENGTH_SHORT).show()
|
||||
|
||||
translator.downloadModelIfNeeded()
|
||||
.addOnSuccessListener {
|
||||
@@ -447,57 +553,58 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
newFile.writeText(translatedSrtContent, Charsets.UTF_8)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@CompletedFilesActivity, "자막 번역 완료!", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(this@CompletedFilesActivity, "'${videoFile.name}' 번역 완료!", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
launchExternalPlayer(videoFile, newFile.absolutePath)
|
||||
onSingleSubtitlePipelineFinished(videoFile, newFile.absolutePath)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("CompletedFilesActivity", "번역 자막 저장 실패", e)
|
||||
withContext(Dispatchers.Main) { launchExternalPlayer(videoFile, originalSubFile.absolutePath) }
|
||||
withContext(Dispatchers.Main) {
|
||||
onSingleSubtitlePipelineFinished(videoFile, originalSubFile.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
Toast.makeText(this, "번역 모델 다운로드에 실패하여 원본 자막으로 재생합니다.", Toast.LENGTH_SHORT).show()
|
||||
launchExternalPlayer(videoFile, originalSubFile.absolutePath)
|
||||
Toast.makeText(this, "번역 모델 다운로드에 실패하여 원본 자막만 저장합니다.", Toast.LENGTH_SHORT).show()
|
||||
onSingleSubtitlePipelineFinished(videoFile, originalSubFile.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 💡 [번역 파이프라인 끝] ---
|
||||
|
||||
private fun launchExternalPlayer(videoFile: File, subtitlePath: String) {
|
||||
private fun launchVlcPlayer(videoFile: File, subtitlePath: String, startPositionMs: Long = 0L) {
|
||||
trackFileAccess(videoFile.name)
|
||||
|
||||
try {
|
||||
val videoUri = FileProvider.getUriForFile(this, "bums.lunatic.launcher.fileprovider", videoFile)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(videoUri, "video/*")
|
||||
setPackage(VLC_PACKAGE_NAME)
|
||||
setDataAndTypeAndNormalize(videoUri, "video/*")
|
||||
putExtra("title", videoFile.nameWithoutExtension)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||
|
||||
if (startPositionMs > 0L) {
|
||||
putExtra("position", startPositionMs)
|
||||
}
|
||||
|
||||
if (subtitlePath.isNotEmpty()) {
|
||||
val subFile = File(subtitlePath)
|
||||
if (subFile.exists()) {
|
||||
val subUri = FileProvider.getUriForFile(this@CompletedFilesActivity, "bums.lunatic.launcher.fileprovider", subFile)
|
||||
val parcelableArray = arrayOf<android.os.Parcelable>(subUri)
|
||||
putExtra("subs", parcelableArray)
|
||||
putExtra("subs.enable", parcelableArray)
|
||||
grantUriPermission(VLC_PACKAGE_NAME, subUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
putExtra("subtitles_location", subUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videoPlayerLauncher.launch(Intent.createChooser(intent, "동영상 재생 앱 선택"))
|
||||
|
||||
videoPlayerLauncher.launch(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(this, "동영상을 재생할 외부 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(this, "VLC 플레이어($VLC_PACKAGE_NAME)가 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// ... (이하 showFolderDeleteDialog, showUnorganizedFilesDialog 등 기존 유틸리티 함수 유지 동일) ...
|
||||
|
||||
private fun showFolderDeleteDialog(folder: File) {
|
||||
val files = folder.listFiles()?.filter { it.isFile }?.sortedWith(NaturalOrderComparator()) ?: emptyList()
|
||||
|
||||
@@ -771,11 +878,30 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
|
||||
findViewById<View>(R.id.btnSelectAll)?.setOnClickListener {
|
||||
selectedFiles.clear()
|
||||
selectedFiles.addAll(adapter.getAll().filter { it.name.contains("..") == false })
|
||||
adapter.updateSelection(selectedFiles)
|
||||
selectedFiles.addAll(adapter.getAll().filter { !it.name.contains("..") })
|
||||
updateSelectionUI()
|
||||
}
|
||||
|
||||
findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
||||
|
||||
findViewById<View>(R.id.btnVideoPreview)?.setOnClickListener {
|
||||
val targetVideo = selectedFiles.singleOrNull()
|
||||
if (targetVideo != null && targetVideo.isFile && extVideos.contains(targetVideo.extension.lowercase())) {
|
||||
toggleSelectionMode(false)
|
||||
videoPreviewHelper.showPreviewDialog(targetVideo, targetFrameCount = 30)
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<View>(R.id.btnSearchSubtitle)?.setOnClickListener {
|
||||
val targetVideos = selectedFiles.filter { it.isFile && extVideos.contains(it.extension.lowercase()) }
|
||||
if (targetVideos.isNotEmpty()) {
|
||||
toggleSelectionMode(false)
|
||||
startSubtitleSearchQueue(targetVideos)
|
||||
} else {
|
||||
Toast.makeText(this, "선택된 영상 파일이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
||||
findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
|
||||
findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
|
||||
@@ -1078,7 +1204,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
||||
currentDir = currentDir.parentFile ?: rootDir
|
||||
loadFiles()
|
||||
} else {
|
||||
finish() // 최상위 폴더에서 뒤로가기 시 액티비티 종료
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,7 +1389,24 @@ class CompletedFilesAdapter(
|
||||
val accessCount = getAccessCount(file.name)
|
||||
val countText = if (accessCount > 0) "조회: ${accessCount}회" else "새 파일"
|
||||
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
|
||||
if (ivThumb != null) Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
|
||||
|
||||
if (ivThumb != null) {
|
||||
val ext = file.extension.lowercase()
|
||||
if (extImages.contains(ext) || extVideos.contains(ext)) {
|
||||
Glide.with(itemView.context)
|
||||
.load(file)
|
||||
.placeholder(android.R.drawable.ic_menu_report_image)
|
||||
.into(ivThumb)
|
||||
} else {
|
||||
Glide.with(itemView.context).clear(ivThumb)
|
||||
val iconRes = if (extDocs.contains(ext)) {
|
||||
android.R.drawable.ic_menu_edit
|
||||
} else {
|
||||
android.R.drawable.ic_menu_report_image
|
||||
}
|
||||
ivThumb.setImageResource(iconRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemView.setOnClickListener { onItemClick(file) }
|
||||
@@ -1274,3 +1417,180 @@ class CompletedFilesAdapter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VideoPreviewHelper(
|
||||
private val context: Context,
|
||||
private val onFrameClick: (videoFile: File, timestampMs: Long) -> Unit
|
||||
) {
|
||||
private var extractJob: Job? = null
|
||||
|
||||
fun showPreviewDialog(videoFile: File, targetFrameCount: Int = 15) {
|
||||
val dialog = BottomSheetDialog(context)
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(24, 32, 24, 24)
|
||||
setBackgroundColor(0xFF1A1A1A.toInt())
|
||||
}
|
||||
|
||||
val tvHeader = TextView(context).apply {
|
||||
text = "🎬 ${videoFile.name}"
|
||||
textSize = 15f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
setPadding(8, 0, 8, 16)
|
||||
maxLines = 1
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
}
|
||||
|
||||
val progressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
isIndeterminate = false
|
||||
max = targetFrameCount
|
||||
progress = 0
|
||||
}
|
||||
|
||||
val recyclerView = RecyclerView(context).apply {
|
||||
layoutManager = GridLayoutManager(context, 3)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(context.resources.displayMetrics.heightPixels * 0.65).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
val items = mutableListOf<VideoFrameItem>()
|
||||
val adapter = FrameGridAdapter(items) { clickedItem ->
|
||||
dialog.dismiss()
|
||||
onFrameClick(videoFile, clickedItem.timestampMs)
|
||||
}
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
container.addView(tvHeader)
|
||||
container.addView(progressBar)
|
||||
container.addView(recyclerView)
|
||||
dialog.setContentView(container)
|
||||
|
||||
dialog.setOnShowListener { dialogInterface ->
|
||||
val bsDialog = dialogInterface as BottomSheetDialog
|
||||
val sheet = bsDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
sheet?.let {
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
behavior.skipCollapsed = true
|
||||
}
|
||||
}
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
extractJob?.cancel()
|
||||
items.forEach { if (!it.bitmap.isRecycled) it.bitmap.recycle() }
|
||||
items.clear()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
|
||||
extractJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(videoFile.absolutePath)
|
||||
val durationMs = retriever.extractMetadata(
|
||||
MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||
)?.toLongOrNull() ?: 0L
|
||||
|
||||
if (durationMs <= 0L) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "영상 길이 정보를 읽을 수 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
dialog.dismiss()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
tvHeader.text = "🎬 ${videoFile.name} (${formatDuration(durationMs)})"
|
||||
}
|
||||
|
||||
val stepMs = durationMs / (targetFrameCount + 1)
|
||||
|
||||
for (i in 1..targetFrameCount) {
|
||||
if (!isActive) break
|
||||
val timeMs = stepMs * i
|
||||
val timeUs = timeMs * 1000L
|
||||
|
||||
val rawBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
retriever.getScaledFrameAtTime(
|
||||
timeUs,
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||
320,
|
||||
180
|
||||
)
|
||||
} else {
|
||||
retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
||||
}
|
||||
|
||||
if (rawBitmap != null) {
|
||||
val frameItem = VideoFrameItem(
|
||||
timestampMs = timeMs,
|
||||
timeLabel = formatDuration(timeMs),
|
||||
bitmap = rawBitmap
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
items.add(frameItem)
|
||||
adapter.notifyItemInserted(items.size - 1)
|
||||
progressBar.progress = i
|
||||
if (i == targetFrameCount) progressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
try { retriever.release() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Long): String {
|
||||
val totalSec = ms / 1000
|
||||
val h = totalSec / 3600
|
||||
val m = (totalSec % 3600) / 60
|
||||
val s = totalSec % 60
|
||||
return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, s)
|
||||
else String.format(Locale.US, "%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
private inner class FrameGridAdapter(
|
||||
private val list: List<VideoFrameItem>,
|
||||
private val onClick: (VideoFrameItem) -> Unit
|
||||
) : RecyclerView.Adapter<FrameGridAdapter.Holder>() {
|
||||
|
||||
inner class Holder(val root: LinearLayout, val iv: ImageView, val tv: TextView) :
|
||||
RecyclerView.ViewHolder(root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
val box = LinearLayout(parent.context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(6, 6, 6, 10)
|
||||
}
|
||||
val iv = ImageView(parent.context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 190)
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setBackgroundColor(0xFF000000.toInt())
|
||||
}
|
||||
val tv = TextView(parent.context).apply {
|
||||
textSize = 11f
|
||||
setTextColor(0xFFCCCCCC.toInt())
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 4, 0, 0)
|
||||
}
|
||||
box.addView(iv)
|
||||
box.addView(tv)
|
||||
return Holder(box, iv, tv)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val item = list[position]
|
||||
holder.iv.setImageBitmap(item.bitmap)
|
||||
holder.tv.text = item.timeLabel
|
||||
holder.root.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = list.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//package bums.lunatic.launcher.home
|
||||
//
|
||||
//import android.content.Context
|
||||
//import android.graphics.Bitmap
|
||||
//import android.media.MediaMetadataRetriever
|
||||
//import android.os.Build
|
||||
//import android.view.LayoutInflater
|
||||
//import android.view.View
|
||||
//import android.view.ViewGroup
|
||||
//import android.widget.ImageView
|
||||
//import android.widget.ProgressBar
|
||||
//import android.widget.TextView
|
||||
//import androidx.recyclerview.widget.GridLayoutManager
|
||||
//import androidx.recyclerview.widget.RecyclerView
|
||||
//import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
//import kotlinx.coroutines.CoroutineScope
|
||||
//import kotlinx.coroutines.Dispatchers
|
||||
//import kotlinx.coroutines.Job
|
||||
//import kotlinx.coroutines.isActive
|
||||
//import kotlinx.coroutines.launch
|
||||
//import kotlinx.coroutines.withContext
|
||||
//import java.io.File
|
||||
//import java.util.Locale
|
||||
//
|
||||
//data class VideoFrameItem(
|
||||
// val timestampMs: Long,
|
||||
// val timeLabel: String,
|
||||
// val bitmap: Bitmap
|
||||
//)
|
||||
//
|
||||
//class VideoPreviewHelper(
|
||||
// private val context: Context,
|
||||
// private val onFrameClick: (videoFile: File, timestampMs: Long) -> Unit
|
||||
//) {
|
||||
// private var extractJob: Job? = null
|
||||
//
|
||||
// fun showPreviewDialog(videoFile: File, targetFrameCount: Int = 12) {
|
||||
// val dialog = BottomSheetDialog(context)
|
||||
// val rootView = LayoutInflater.from(context).inflate(
|
||||
// android.R.layout.list_content, null
|
||||
// )
|
||||
//
|
||||
// // 별도 XML 없이 코드로 깔끔하게 구성한 컨테이너 (필요 시 전용 XML로 분리 가능)
|
||||
// val container = android.widget.LinearLayout(context).apply {
|
||||
// orientation = android.widget.LinearLayout.VERTICAL
|
||||
// setPadding(24, 32, 24, 24)
|
||||
// setBackgroundColor(0xFF1A1A1A.toInt())
|
||||
// }
|
||||
//
|
||||
// val tvHeader = TextView(context).apply {
|
||||
// text = "🎬 ${videoFile.name}"
|
||||
// textSize = 15f
|
||||
// setTextColor(0xFFFFFFFF.toInt())
|
||||
// setPadding(8, 0, 8, 20)
|
||||
// maxLines = 1
|
||||
// ellipsize = android.text.TextUtils.TruncateAt.END
|
||||
// }
|
||||
//
|
||||
// val progressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
// isIndeterminate = false
|
||||
// max = targetFrameCount
|
||||
// progress = 0
|
||||
// }
|
||||
//
|
||||
// val recyclerView = RecyclerView(context).apply {
|
||||
// layoutManager = GridLayoutManager(context, 3) // 3열 그리드 나열
|
||||
// layoutParams = ViewGroup.LayoutParams(
|
||||
// ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
// (context.resources.displayMetrics.heightPixels * 0.65).toInt()
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// val items = mutableListOf<VideoFrameItem>()
|
||||
// val adapter = FrameGridAdapter(items) { clickedItem ->
|
||||
// dialog.dismiss()
|
||||
// onFrameClick(videoFile, clickedItem.timestampMs)
|
||||
// }
|
||||
// recyclerView.adapter = adapter
|
||||
//
|
||||
// container.addView(tvHeader)
|
||||
// container.addView(progressBar)
|
||||
// container.addView(recyclerView)
|
||||
// dialog.setContentView(container)
|
||||
//
|
||||
// dialog.setOnDismissListener {
|
||||
// extractJob?.cancel()
|
||||
// // 메모리 회수
|
||||
// items.forEach { if (!it.bitmap.isRecycled) it.bitmap.recycle() }
|
||||
// items.clear()
|
||||
// }
|
||||
//
|
||||
// dialog.show()
|
||||
//
|
||||
// // 백그라운드에서 키프레임 추출 및 순차 렌더링
|
||||
// extractJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
// val retriever = MediaMetadataRetriever()
|
||||
// try {
|
||||
// retriever.setDataSource(videoFile.absolutePath)
|
||||
// val durationMs = retriever.extractMetadata(
|
||||
// MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||
// )?.toLongOrNull() ?: 0L
|
||||
//
|
||||
// if (durationMs <= 0L) return@launch
|
||||
//
|
||||
// // 시작(0초)과 끝 직전 여백을 두고 균등 간격 산출
|
||||
// val stepMs = durationMs / (targetFrameCount + 1)
|
||||
//
|
||||
// for (i in 1..targetFrameCount) {
|
||||
// if (!isActive) break
|
||||
// val timeMs = stepMs * i
|
||||
// val timeUs = timeMs * 1000L
|
||||
//
|
||||
// val rawBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
// // API 27+: 추출 단계에서 가로 320px로 즉시 축소하여 메모리 절약
|
||||
// retriever.getScaledFrameAtTime(
|
||||
// timeUs,
|
||||
// MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||
// 320,
|
||||
// 180
|
||||
// )
|
||||
// } else {
|
||||
// retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
||||
// }
|
||||
//
|
||||
// if (rawBitmap != null) {
|
||||
// val frameItem = VideoFrameItem(
|
||||
// timestampMs = timeMs,
|
||||
// timeLabel = formatDuration(timeMs),
|
||||
// bitmap = rawBitmap
|
||||
// )
|
||||
// withContext(Dispatchers.Main) {
|
||||
// items.add(frameItem)
|
||||
// adapter.notifyItemInserted(items.size - 1)
|
||||
// progressBar.progress = i
|
||||
// if (i == targetFrameCount) progressBar.visibility = View.GONE
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// } finally {
|
||||
// try { retriever.release() } catch (_: Exception) {}
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private fun formatDuration(ms: Long): String {
|
||||
// val totalSec = ms / 1000
|
||||
// val h = totalSec / 3600
|
||||
// val m = (totalSec % 3600) / 60
|
||||
// val s = totalSec % 60
|
||||
// return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, s)
|
||||
// else String.format(Locale.US, "%02d:%02d", m, s)
|
||||
// }
|
||||
//
|
||||
// private inner class FrameGridAdapter(
|
||||
// private val list: List<VideoFrameItem>,
|
||||
// private val onClick: (VideoFrameItem) -> Unit
|
||||
// ) : RecyclerView.Adapter<FrameGridAdapter.Holder>() {
|
||||
//
|
||||
// inner class Holder(val root: android.widget.LinearLayout, val iv: ImageView, val tv: TextView) :
|
||||
// RecyclerView.ViewHolder(root)
|
||||
//
|
||||
// override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
// val box = android.widget.LinearLayout(parent.context).apply {
|
||||
// orientation = android.widget.LinearLayout.VERTICAL
|
||||
// setPadding(6, 6, 6, 10)
|
||||
// }
|
||||
// val iv = ImageView(parent.context).apply {
|
||||
// layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 190)
|
||||
// scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
// setBackgroundColor(0xFF000000.toInt())
|
||||
// }
|
||||
// val tv = TextView(parent.context).apply {
|
||||
// textSize = 11f
|
||||
// setTextColor(0xFFCCCCCC.toInt())
|
||||
// gravity = android.view.Gravity.CENTER
|
||||
// setPadding(0, 4, 0, 0)
|
||||
// }
|
||||
// box.addView(iv)
|
||||
// box.addView(tv)
|
||||
// return Holder(box, iv, tv)
|
||||
// }
|
||||
//
|
||||
// override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
// val item = list[position]
|
||||
// holder.iv.setImageBitmap(item.bitmap)
|
||||
// holder.tv.text = item.timeLabel
|
||||
// holder.root.setOnClickListener { onClick(item) }
|
||||
// }
|
||||
//
|
||||
// override fun getItemCount() = list.size
|
||||
// }
|
||||
//}
|
||||
@@ -105,10 +105,9 @@ class WebReaderActivity : AppCompatActivity() {
|
||||
|
||||
// 🌟 수정된 부분: 인터넷 상태를 먼저 체크합니다!
|
||||
if (isNetworkConnected()) {
|
||||
// 온라인이면 서버로 접속
|
||||
webView.loadUrl(SERVER_URL)
|
||||
// 뒤에 ?v=현재시간 을 붙여서 서버/프록시 캐시 우회 (reader.html의 ?id= 파싱에는 영향 없음)
|
||||
webView.loadUrl("$SERVER_URL?v=${System.currentTimeMillis()}")
|
||||
} else {
|
||||
// 오프라인이면 즉시 로컬 에셋(위조 모드)으로 실행
|
||||
loadLocalHtmlAsServer()
|
||||
}
|
||||
|
||||
@@ -128,6 +127,7 @@ class WebReaderActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun setupWebView() {
|
||||
webView.clearCache(true)
|
||||
webView.settings.apply {
|
||||
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
javaScriptEnabled = true
|
||||
@@ -190,6 +190,10 @@ class WebReaderActivity : AppCompatActivity() {
|
||||
request: WebResourceRequest?
|
||||
): WebResourceResponse? {
|
||||
val url = request?.url.toString()
|
||||
if (url.contains("sw.js")) {
|
||||
// 서비스 워커 스크립트 차단 (네이티브 앱은 assets 폴백을 쓰므로 불필요)
|
||||
return WebResourceResponse("text/javascript", "UTF-8", "".byteInputStream())
|
||||
}
|
||||
if (url.contains("jszip.min.js")) {
|
||||
return try {
|
||||
val inputStream = assets.open("jszip.min.js")
|
||||
|
||||
@@ -27,6 +27,13 @@ import bums.lunatic.launcher.workers.WorkersDb
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Calendar
|
||||
|
||||
|
||||
class NLService : NotificationListenerService() {
|
||||
@@ -145,12 +152,84 @@ class NLService : NotificationListenerService() {
|
||||
}
|
||||
makeMsgByTransferInfomation(defaultMsg)
|
||||
}
|
||||
// else if (stringBuffer.contains("홈택스") && stringBuffer.contains("인증") && stringBuffer.contains(
|
||||
// "요청"
|
||||
// ) && stringBuffer.contains("도착")
|
||||
// ) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
// println("카톡::\n${stringBuffer.toString()}")
|
||||
}
|
||||
|
||||
"kakaopay.app" -> {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
val cleanContent = stringBuffer.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct() // title, text, bigText 중복 줄 제거
|
||||
.joinToString("\n")
|
||||
|
||||
val ignoredPackages = setOf("android", "com.android.systemui")
|
||||
if (cleanContent.isNotEmpty() && !ignoredPackages.contains(sbn.packageName)) {
|
||||
saveNotificationToFile(sbn.packageName, cleanContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val fileWriteLock = Any()
|
||||
|
||||
private fun saveNotificationToFile(packageName: String, content: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val now = Date()
|
||||
val calendar = Calendar.getInstance().apply { time = now }
|
||||
val hour = calendar.get(Calendar.HOUR_OF_DAY)
|
||||
|
||||
val yearStr = SimpleDateFormat("yyyy", Locale.getDefault()).format(now)
|
||||
val monthStr = SimpleDateFormat("MM", Locale.getDefault()).format(now)
|
||||
val dateStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(now)
|
||||
val fullTimeStr = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(now)
|
||||
|
||||
// 4시간 단위 구간 계산 (00-04, 04-08, 08-12, 12-16, 16-20, 20-24)
|
||||
val startHour = (hour / 4) * 4
|
||||
val endHour = startHour + 4
|
||||
val slotSuffix = String.format(Locale.US, "%02d-%02d", startHour, endHour)
|
||||
|
||||
// 경로: completed_torrents/Documents/Noti/2026/11/push_2026-11-13_12-16.txt
|
||||
val rootDir = File(getExternalFilesDir(null), "completed_torrents")
|
||||
val targetDir = File(rootDir, "Documents/Noti/$yearStr/$monthStr").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val logFile = File(targetDir, "push_${dateStr}_${slotSuffix}.txt")
|
||||
|
||||
// 앱 이름 조회 (실패 시 패키지명으로 대체)
|
||||
val appName = try {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} catch (e: Exception) {
|
||||
packageName
|
||||
}
|
||||
|
||||
val entry = buildString {
|
||||
append("========================================\n")
|
||||
append("■ 패키지명 : ").append(packageName).append("\n")
|
||||
append("■ 앱이름 : ").append(appName).append("\n")
|
||||
append("■ 시간 : ").append(fullTimeStr).append("\n")
|
||||
append("■ 내용 :\n")
|
||||
append(content).append("\n")
|
||||
append("========================================\n\n")
|
||||
}
|
||||
|
||||
synchronized(fileWriteLock) {
|
||||
logFile.appendText(entry, Charsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="#EEEEEE"
|
||||
android:padding="8dp"
|
||||
android:padding="4dp"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:id="@+id/btnSelectAll"
|
||||
@@ -126,58 +126,95 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="전체"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
android:paddingVertical="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/btnCancelSelection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="취소"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
android:paddingVertical="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/btnVideoPreview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.2"
|
||||
android:text="미리보기"
|
||||
android:textColor="#00897B"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:paddingVertical="12dp"
|
||||
android:visibility="gone"/>
|
||||
<TextView
|
||||
android:id="@+id/btnSearchSubtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.2"
|
||||
android:text="자막 검색"
|
||||
android:textColor="#0066CC"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:paddingVertical="12dp"
|
||||
android:visibility="gone"/>
|
||||
<TextView
|
||||
android:id="@+id/btnRenameSelected"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_weight="1.2"
|
||||
android:text="이름 변경"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
android:paddingVertical="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/btnCopySelected"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="외부폴더로 복사"
|
||||
android:layout_weight="1.2"
|
||||
android:text="외부 복사"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
android:paddingVertical="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/btnMoveSelected"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_weight="1.2"
|
||||
android:text="폴더 이동"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
android:paddingVertical="12dp"/>
|
||||
<TextView
|
||||
android:id="@+id/btnDeleteSelected"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_weight="1.2"
|
||||
android:text="선택 삭제"
|
||||
android:textColor="#FF0000"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
android:paddingVertical="12dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 💡 자막 다운로드 파싱용 GeckoWeb 추가 (화면에 보이지 않게 처리) -->
|
||||
<bums.lunatic.launcher.home.GeckoWeb
|
||||
android:id="@+id/geckoWebFragment"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
Reference in New Issue
Block a user