Compare commits

...
42 Commits
Author SHA1 Message Date
lun_admin d73a1dda56 ... 2026-08-18 11:28:54 +09:00
lun_admin 2177452f52 .. 2026-08-12 18:08:18 +09:00
lun_admin b526fe4670 ... 2026-08-12 17:20:03 +09:00
lun_admin 1f696902da ... 2026-08-12 15:18:33 +09:00
lun_admin 14e26702c1 ... 2026-08-10 17:24:04 +09:00
lun_admin ad505863b7 ... 2026-08-03 17:52:49 +09:00
lun_admin 24a2a74768 .. 2026-07-09 17:29:23 +09:00
lun_admin 278c1b4510 .. 2026-07-09 16:47:21 +09:00
lun_admin dab91b1a65 ... 2026-06-30 10:00:31 +09:00
lun_admin 0400385cf8 ... 2026-06-08 14:45:38 +09:00
lun_admin 378f6495be .. 2026-05-20 17:57:56 +09:00
lun_admin 257ddb6776 ... 2026-04-30 17:28:30 +09:00
lun_admin c5d0a827e9 ... 2026-04-21 17:10:59 +09:00
lun_admin b64df76c90 ... 2026-04-21 16:36:43 +09:00
lun_admin ac1c0b12b3 ... 2026-04-21 15:37:32 +09:00
lun_admin fd0ee96c04 ... 2026-04-21 14:02:33 +09:00
lun_admin c8000d022c ... 2026-04-20 18:09:43 +09:00
lun_admin 5b86fc7e2b ... 2026-04-20 17:48:09 +09:00
lun_admin 6813a6bdd7 /... 2026-04-20 17:10:15 +09:00
lun_admin 5ded732345 ... 2026-04-15 15:04:20 +09:00
lun_admin fa5e6bbedc s 2026-04-12 23:43:58 +09:00
lun_admin d0422fafeb .. 2026-04-11 17:32:14 +09:00
lun_admin 2327326cca ... 2026-04-10 15:48:16 +09:00
lun_admin 162d2b7563 ... 2026-04-10 15:31:07 +09:00
lun_admin 964655af7f . 2026-04-09 21:19:09 +09:00
lun_admin 0405629106 .... 2026-04-09 18:24:49 +09:00
lun_admin f9e8c3de37 ... 2026-04-09 17:31:08 +09:00
lun_admin 1da8d5bb62 ... 2026-04-09 17:16:23 +09:00
lun_admin 5e7c121cf8 .. 2026-04-08 17:25:14 +09:00
lun_admin 274eaa3213 .... 2026-04-08 16:11:52 +09:00
lun_admin 80c06c5ab3 ... 2026-04-08 15:58:56 +09:00
lun_admin d7647f57e8 .. 2026-04-05 09:30:18 +09:00
lun_admin ba869e8eb4 ... 2026-04-05 09:04:46 +09:00
lun_admin 8c3bc96417 ... 2026-04-03 16:03:50 +09:00
lun_admin dc19ff5339 ... 2026-04-02 18:18:11 +09:00
lun_admin e9e585bad0 ... 2026-04-01 17:59:30 +09:00
lun_admin 9526104a4a .. 2026-04-01 11:12:06 +09:00
lun_admin 92d228d2e0 ... 2026-03-31 18:15:28 +09:00
lun_admin 5a8e2293ca ... 2026-03-31 11:38:44 +09:00
lun_admin 2ed5957365 ... 2026-03-30 14:49:08 +09:00
lun_admin 86f387b2fa ... 2026-03-30 14:28:54 +09:00
lun_admin 57039939fd ... 2026-03-30 14:10:05 +09:00
89 changed files with 15507 additions and 580 deletions
+10 -1
View File
@@ -168,13 +168,17 @@ dependencies {
// implementation(project(":utils"))
implementation( "com.github.bumptech.glide:glide:4.11.0")
implementation ("com.github.bumptech.glide:okhttp3-integration:4.11.0")
// implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407")
implementation ("com.github.chrisbanes:PhotoView:2.3.0")
implementation ("androidx.exifinterface:exifinterface:1.3.6")
// https://mvnrepository.com/artifact/org.mozilla.geckoview/geckoview
implementation("org.mozilla.geckoview:geckoview:139.0.20250523173407")
implementation("com.vladsch.flexmark:flexmark-all:0.64.8")
// implementation 'com.vladsch.flexmark:flexmark-all:0.64.8'
// implementation("org.opencv:opencv-android:4.11.0")
// build.gradle에 추가
implementation ("androidx.emoji2:emoji2:1.4.0")
implementation ("com.googlecode.juniversalchardet:juniversalchardet:1.0.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// implementation ("com.github.aeonSolutions:FloatingActionButtonMenuDrag:1.1")
implementation("io.github.junkfood02.youtubedl-android:library:0.17.4")
@@ -229,6 +233,11 @@ dependencies {
// (선택) CameraX 확장 라이브러리 (보케, HDR 등)
implementation("androidx.camera:camera-extensions:$camerax_version")
implementation("com.google.mlkit:translate:17.0.3") // 버전 살짝 업그레이드 권장
implementation("com.google.mlkit:language-id:17.0.5")
implementation("com.google.android.gms:play-services-base:18.5.0")
implementation("com.google.mlkit:common:18.11.0")
constraints {
// ⚠️ 이 버전을 프로젝트 루트의 build.gradle.kts에 정의된 kotlinVersion 값과 정확히 일치시키세요.
val targetKotlinVersion = "2.0.20"
+68 -8
View File
@@ -62,6 +62,14 @@
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<queries>
<package android:name="com.google.android.gms" />
<intent>
<action android:name="com.google.android.gms.actions.SEARCH_ACTION" />
</intent>
</queries>
<application
android:name=".LunaticLauncher"
android:icon="@drawable/ic_launcher"
@@ -77,7 +85,6 @@
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
android:screenOrientation="nosensor"
android:windowSoftInputMode="adjustResize"
android:requestLegacyExternalStorage="true"
tools:ignore="HardcodedDebugMode">
@@ -85,7 +92,7 @@
<activity
android:name=".home.NeoRssActivity"
android:theme="@style/Theme.LunarLauncher.Starting"
android:theme="@style/Theme.Player"
android:launchMode="singleInstance"
android:screenOrientation="userPortrait"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|screenLayout|layoutDirection|navigation"
@@ -105,6 +112,57 @@
<data android:mimeType="*/*"/>
</intent-filter>
</activity>
<activity
android:name=".home.TranslatorActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".player.PlayerActivity"
android:theme="@style/Theme.Player"
android:launchMode="singleInstance"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:screenOrientation="sensor"
android:hardwareAccelerated="true"
android:excludeFromRecents="true"
android:exported="false">
</activity>
<activity android:name=".home.WebReaderActivity"
android:theme="@style/Theme.Player"
android:icon="@drawable/ic_launcher"
android:roundIcon="@drawable/ic_launcher"
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:launchMode="singleTask"> <intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".player.DocumentViewerActivity"
android:theme="@style/Theme.Player"
android:launchMode="singleInstance"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|fontScale|keyboardHidden|keyboard|layoutDirection|navigation"
android:screenOrientation="portrait"
android:excludeFromRecents="false"
android:hardwareAccelerated="true"
android:exported="false">
</activity>
<activity
android:name=".player.ImageViewerActivity"
android:theme="@style/Theme.Player"
android:launchMode="singleInstance"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:screenOrientation="sensor"
android:excludeFromRecents="true"
android:hardwareAccelerated="true"
android:exported="false">
</activity>
<activity
android:name=".LauncherActivity"
@@ -141,7 +199,7 @@
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>
<receiver android:name=".receiver.SmsReceiver"
<receiver android:name=".receiver.SmsReceiver"
android:exported="true"> <intent-filter android:priority="2147483647">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
<action android:name="android.provider.Telephony.WAP_PUSH_RECEIVED" />
@@ -197,10 +255,10 @@
</intent-filter>
</activity>
<!-- <service-->
<!-- android:name=".feeds.rss.RssService"-->
<!-- android:permission="android.permission.BIND_JOB_SERVICE"-->
<!-- android:exported="false"/>-->
<!-- <service-->
<!-- android:name=".feeds.rss.RssService"-->
<!-- android:permission="android.permission.BIND_JOB_SERVICE"-->
<!-- android:exported="false"/>-->
<provider
android:name="androidx.core.content.FileProvider"
@@ -224,6 +282,8 @@
</service>
<service android:name="bums.lunatic.launcher.workers.LocationUpdateService" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="ocr,langid,translate" />
</application>
</manifest>
@@ -0,0 +1,152 @@
// 페이지 로딩 완료 후 실행
window.addEventListener('load', async () => {
// 1. URL 패턴 확인
const match = window.location.pathname.match(/\/novel\/(\d+)\/(\d+)/);
if (!match) return;
const bookId = match[1];
const chapterId = match[2];
console.log(`웹소설 스크래퍼: 책 ID [${bookId}] 감지됨.`);
// ⏳ 2. 1~2초 동안 '페이지 다운(80%)' 스크롤 실행
const initialScrollTime = Math.floor(Math.random() * (10000 - 1000 + 1)) + 8000;
console.log(`${(initialScrollTime / 1000).toFixed(1)}초 동안 큼직하게 훑어보며 대기합니다... 👀`);
await pageSkimScroll(initialScrollTime);
// 3. 데이터 스크래핑
const scrapedData = scrapePage();
if (!scrapedData) return;
// 4. 책 ID별 Storage 저장
chrome.storage.local.get(['scrapedBooks'], async (result) => {
let scrapedBooks = result.scrapedBooks || {};
if (!scrapedBooks[bookId]) {
scrapedBooks[bookId] = {
id: 'book_' + bookId,
fileName: scrapedData.novelTitle,
chapters: []
};
}
let bookData = scrapedBooks[bookId];
if (scrapedData.novelTitle !== '제목없음') {
bookData.fileName = scrapedData.novelTitle;
}
// 중복 챕터 검사
const isDuplicate = bookData.chapters.some(chap => chap.chapterId === chapterId);
if (isDuplicate) {
console.log(`⚠️ 이미 저장된 챕터입니다 (ID: ${chapterId}). 자동 진행을 중지합니다.`);
return;
}
// 챕터 추가
bookData.chapters.push({
chapterId: chapterId,
title: scrapedData.chapterTitle,
content: scrapedData.content
});
// Storage에 저장
scrapedBooks[bookId] = bookData;
chrome.storage.local.set({
scrapedBooks: scrapedBooks,
currentBookId: bookId
}, async () => {
console.log(`✅ [${scrapedData.novelTitle} - ${scrapedData.chapterTitle}] 저장 완료!`);
// ⏳ 5. 저장 완료 후 2~3초 랜덤 대기
const waitDelay = Math.floor(Math.random() * (5000 - 2000 + 1)) + 4000;
console.log(`${(waitDelay / 1000).toFixed(1)}초 대기 후 다음 챕터로 이동합니다... ⏳`);
await new Promise(resolve => setTimeout(resolve, waitDelay));
// '다음화' 버튼 클릭
const buttons = Array.from(document.querySelectorAll('a.btn.btn-black.btn-sm'));
const nextButton = buttons.find(btn => btn.innerText.trim().includes('다음화'));
if (nextButton) {
console.log("👉 다음 화 버튼 클릭 진행");
nextButton.click();
} else {
console.log("⚠️ '다음화' 버튼을 찾을 수 없거나 마지막 화입니다.");
}
});
});
});
// ==========================================
// 🚀 화면 높이의 약 80%씩 큼직하게 스크롤하는 함수
// ==========================================
function pageSkimScroll(duration) {
return new Promise((resolve) => {
let elapsed = 0;
const scrollLoop = () => {
if (elapsed >= duration) {
resolve();
return;
}
// 400ms ~ 700ms 사이의 랜덤한 대기 시간 (큼직하게 내리기 때문에 간격을 조금 더 줌)
const stepTime = Math.floor(Math.random() * (1700 - 400 + 1)) + 1400;
// 현재 브라우저 창 높이(Viewport)를 구함
const screenHeight = window.innerHeight;
// 75% ~ 85% 사이의 랜덤한 비율 설정 (평균 80%)
const scrollRatio = 0.75 + (Math.random() * 0.1);
const stepAmount = screenHeight * scrollRatio;
// 부드럽게 스크롤
window.scrollBy({ top: stepAmount, behavior: 'smooth' });
elapsed += stepTime;
setTimeout(scrollLoop, stepTime);
};
// 루프 시작
scrollLoop();
});
}
// ==========================================
// 스크래핑 헬퍼 함수
// ==========================================
function scrapePage() {
const titleEl = document.querySelector('.page-title h2');
let novelTitle = titleEl ? titleEl.innerText.trim() : '제목없음';
let chapterTitle = document.querySelector('.theme-novel-title');
chapterTitle = chapterTitle ? chapterTitle.innerText.trim() : '1화';
let novelContent = '';
const contentHost = document.querySelector('.theme-novel-content');
if (contentHost) {
if (contentHost.shadowRoot) {
const pTags = contentHost.shadowRoot.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.innerText.trim());
novelContent = textArray.join('\n\n');
} else {
const template = contentHost.querySelector('template[shadowrootmode="open"]');
if (template && template.content) {
const pTags = template.content.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.textContent.trim());
novelContent = textArray.join('\n\n');
}
}
}
if (!novelContent) {
console.warn('본문을 추출할 수 없습니다.');
return null;
}
return {
novelTitle: novelTitle,
chapterTitle: chapterTitle,
content: novelContent
};
}
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "웹소설 스크래퍼 (나만의 서재용)",
"version": "1.2",
"description": "웹소설 챕터를 수집하여 서재 리더기용 JSON으로 다운로드합니다.",
"action": {
"default_popup": "popup.html"
},
"permissions": [
"activeTab",
"scripting",
"storage",
"downloads",
"unlimitedStorage"
],
"host_permissions": [
"https://api.telegram.org/*"
],
"content_scripts": [
{
"matches": [
"*://newtoki1.org/novel/*",
"*://*.newtoki1.org/novel/*"
],
"js": ["content.js"],
"run_at": "document_end"
}
]
}
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body { width: 280px; padding: 15px; font-family: 'Malgun Gothic', sans-serif; background: #f9f9f9; }
h3 { margin-top: 0; text-align: center; color: #333; }
button { width: 100%; padding: 10px; margin-bottom: 8px; cursor: pointer; border: none; border-radius: 6px; font-weight: bold; font-size: 13px; transition: 0.2s; }
button:hover { opacity: 0.9; }
#btn-save { background: #3498db; color: white; }
#btn-download { background: #27ae60; color: white; }
#btn-telegram { background: #0088cc; color: white; } /* 텔레그램 시그니처 색상 */
#btn-clear { background: #e74c3c; color: white; }
.tg-config { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #ddd; }
.tg-config input { width: 92%; padding: 6px; margin-top: 4px; margin-bottom: 8px; font-size: 11px; border: 1px solid #ccc; border-radius: 4px; }
.tg-config label { font-size: 11px; font-weight: bold; color: #555; }
#status { font-size: 12px; color: #666; text-align: center; margin-top: 5px; padding: 10px; background: #eee; border-radius: 6px; }
</style>
</head>
<body>
<h3>📖 소설 스크래퍼</h3>
<div class="tg-config">
<label>Bot Token:</label>
<input type="password" id="tg-token" placeholder="BotFather에게 받은 토큰">
<label>Chat ID:</label>
<input type="text" id="tg-chatid" placeholder="숫자로 된 Chat ID">
<button id="btn-save-config" style="background:#666; color:#fff; padding:5px; font-size:11px; margin-bottom:0;">⚙️ 텔레그램 설정 저장</button>
</div>
<button id="btn-save"> 현재 화면(챕터) 저장하기</button>
<button id="btn-download">📥 리더기용 JSON 다운로드</button>
<button id="btn-telegram">🚀 텔레그램으로 JSON 전송</button>
<button id="btn-clear">🗑️ 현재 소설 데이터 초기화</button>
<div id="status">대기 중...</div>
<script src="popup.js"></script>
</body>
</html>
@@ -0,0 +1,267 @@
document.addEventListener('DOMContentLoaded', () => {
updateStatus();
loadTelegramConfig();
// 1. [설정 저장] 버튼
document.getElementById('btn-save-config').addEventListener('click', saveTelegramConfig);
// 2. [저장하기] 버튼
// 2. [저장하기] 버튼
document.getElementById('btn-save').addEventListener('click', async () => {
let [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.scripting.executeScript({
target: { tabId: tab.id },
function: scrapePage
}, (results) => {
if (results && results[0] && results[0].result) {
const data = results[0].result;
const match = tab.url ? tab.url.match(/\/novel\/(\d+)\/(\d+)/) : null;
const bookId = match ? match[1] : 'manual_' + Date.now();
const chapterId = match ? match[2] : 'manual_chap_' + Date.now(); // 챕터 ID 추가
// chapterId를 같이 넘겨줍니다.
saveToExtensionStorage(data, bookId, chapterId);
}
});
});
// 3. [다운로드] 버튼
document.getElementById('btn-download').addEventListener('click', downloadJson);
// 4. [텔레그램 전송] 버튼
document.getElementById('btn-telegram').addEventListener('click', sendToTelegram);
// 5. [초기화] 버튼
document.getElementById('btn-clear').addEventListener('click', clearStorage);
});
// 텔레그램 설정 불러오기
function loadTelegramConfig() {
chrome.storage.local.get(['tgToken', 'tgChatId'], (result) => {
if (result.tgToken) document.getElementById('tg-token').value = result.tgToken;
if (result.tgChatId) document.getElementById('tg-chatid').value = result.tgChatId;
});
}
// 텔레그램 설정 저장
function saveTelegramConfig() {
const token = document.getElementById('tg-token').value.trim();
const chatId = document.getElementById('tg-chatid').value.trim();
chrome.storage.local.set({ tgToken: token, tgChatId: chatId }, () => {
alert("✅ 텔레그램 설정이 저장 되었습니다!");
});
}
// 🚀 텔레그램 API로 JSON 파일 전송 함수
function sendToTelegram() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId', 'tgToken', 'tgChatId'], async (result) => {
const token = result.tgToken;
const chatId = result.tgChatId;
if (!token || !chatId) {
alert("⚠️ 먼저 텔레그램 Bot Token과 Chat ID를 설정하고 저장해주세요!");
return;
}
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
const bookData = currentBookId ? books[currentBookId] : null;
if (!bookData || bookData.chapters.length === 0) {
alert("전송할 데이터가 없습니다.");
return;
}
// 리더기 포맷 페이로드 생성
const payload = {
type: 'single_book',
book: bookData,
progress: { chapter: 0, page: 0, tags: [], memo: "스크래퍼 확장프로그램으로 수집됨", isFavorite: false, isCompleted: false }
};
const jsonString = JSON.stringify(payload, null, 2);
const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_');
// JSON 문자열을 Blob 파일 객체로 변환
const blob = new Blob([jsonString], { type: 'application/json' });
// Multipart FormData 생성 (텔레그램 API 전송용)
const formData = new FormData();
formData.append('chat_id', chatId);
formData.append('document', blob, `book_${safeFileName}.json`);
formData.append('caption', `📖 [${bookData.fileName}]\n${bookData.chapters.length}화 수집 완료 JSON 파일입니다.`);
// 버튼 상태 변경
const btnTg = document.getElementById('btn-telegram');
btnTg.innerText = "⏳ 전송 중...";
btnTg.disabled = true;
try {
// Telegram sendDocument API 호출
const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
method: 'POST',
body: formData
});
const resData = await response.json();
if (resData.ok) {
alert(`🚀 [${bookData.fileName}] 텔레그램으로 전송 성공!`);
} else {
alert(`❌ 전송 실패: ${resData.description}`);
}
} catch (error) {
console.error(error);
alert("❌ 네트워크 오류가 발생했습니다.");
} finally {
btnTg.innerText = "🚀 텔레그램으로 JSON 전송";
btnTg.disabled = false;
}
});
}
// 스크래핑 함수 (수동 저장용)
function scrapePage() {
const titleEl = document.querySelector('.page-title h2');
let novelTitle = titleEl ? titleEl.innerText.trim() : '제목없음';
let chapterTitle = document.querySelector('.theme-novel-title');
chapterTitle = chapterTitle ? chapterTitle.innerText.trim() : '1화';
let novelContent = '';
const contentHost = document.querySelector('.theme-novel-content');
if (contentHost) {
if (contentHost.shadowRoot) {
const pTags = contentHost.shadowRoot.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.innerText.trim());
novelContent = textArray.join('\n\n');
} else {
const template = contentHost.querySelector('template[shadowrootmode="open"]');
if (template && template.content) {
const pTags = template.content.querySelectorAll('p');
const textArray = Array.from(pTags).map(p => p.textContent.trim());
novelContent = textArray.join('\n\n');
}
}
}
if (!novelContent) {
alert('본문을 추출할 수 없습니다.');
return null;
}
return {
novelTitle: novelTitle,
chapterTitle: chapterTitle,
content: novelContent
};
}
// 수동 저장 함수
function saveToExtensionStorage(scrapedData, bookId) {
chrome.storage.local.get(['scrapedBooks'], (result) => {
let scrapedBooks = result.scrapedBooks || {};
if (!scrapedBooks[bookId]) {
scrapedBooks[bookId] = {
id: 'book_' + bookId,
fileName: scrapedData.novelTitle,
chapters: []
};
}
let bookData = scrapedBooks[bookId];
// 챕터 ID 기준으로 중복 검사
const isDuplicate = bookData.chapters.some(chap => chap.chapterId === chapterId);
if (isDuplicate) {
alert('⚠️ 이미 저장된 챕터입니다!');
return;
}
bookData.chapters.push({
chapterId: chapterId,
title: scrapedData.chapterTitle,
content: scrapedData.content
});
scrapedBooks[bookId] = bookData;
chrome.storage.local.set({ scrapedBooks: scrapedBooks, currentBookId: bookId }, () => {
updateStatus();
alert(`✅ [${scrapedData.chapterTitle}] 저장 완료!`);
});
});
}
// 다운로드 함수
function downloadJson() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => {
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
const bookData = currentBookId ? books[currentBookId] : null;
if (!bookData || bookData.chapters.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const payload = {
type: 'single_book',
book: bookData,
progress: { chapter: 0, page: 0, tags: [], memo: "스크래퍼 확장프로그램으로 수집됨", isFavorite: false, isCompleted: false }
};
const jsonString = JSON.stringify(payload, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_');
chrome.downloads.download({
url: url,
filename: `book_${safeFileName}.json`,
saveAs: true
});
});
}
// 초기화 함수
function clearStorage() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => {
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
if (!currentBookId || !books[currentBookId]) {
alert("삭제할 수집 데이터가 없습니다.");
return;
}
const bookTitle = books[currentBookId].fileName;
if(confirm(`[${bookTitle}] 수집 데이터를 삭제하시겠습니까?`)) {
delete books[currentBookId];
chrome.storage.local.set({ scrapedBooks: books, currentBookId: null }, () => {
updateStatus();
});
}
});
}
// 상태창 업데이트
function updateStatus() {
chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => {
const books = result.scrapedBooks || {};
const currentBookId = result.currentBookId;
const book = currentBookId ? books[currentBookId] : null;
const count = book ? book.chapters.length : 0;
const title = book ? book.fileName : '없음';
document.getElementById('status').innerHTML = `
<strong>현재 선택 소설:</strong> ${title}<br>
<strong style="color:#27ae60; font-size:14px;">모인 챕터: ${count}화</strong>
`;
});
}
@@ -0,0 +1,85 @@
// ==========================================
// 1. 챕터를 로컬 스토리지에 누적 저장하는 함수
// ==========================================
function saveChapterToLocal(bookTitle, chapterTitle, chapterContent) {
const STORAGE_KEY = 'webReader_scraper_temp';
let bookData = JSON.parse(localStorage.getItem(STORAGE_KEY));
// 기존 데이터가 없거나, 다른 책을 스크랩하기 시작한 경우 초기화
if (!bookData || bookData.fileName !== bookTitle) {
bookData = {
id: 'book_scraped_' + Date.now(), // 고유 ID 생성
fileName: bookTitle,
chapters: []
};
console.log(`새로운 책 [${bookTitle}] 스크랩을 시작합니다.`);
}
// 중복 저장 방지 로직 (동일한 챕터 제목이 있는지 검사)
const isDuplicate = bookData.chapters.some(chap => chap.title === chapterTitle);
if (isDuplicate) {
console.warn(`⚠️ 이미 저장된 챕터입니다: ${chapterTitle}`);
return false;
}
// 챕터 데이터 추가
bookData.chapters.push({
title: chapterTitle,
content: chapterContent
});
// 변경된 데이터를 다시 스토리지에 저장
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookData));
console.log(`✅ [${bookTitle}] ${chapterTitle} 저장 완료! (현재 총 ${bookData.chapters.length}화)`);
return true;
}
// ==========================================
// 2. 누적된 데이터를 리더기 호환 JSON으로 다운로드하는 함수
// ==========================================
function downloadBookAsJson() {
const STORAGE_KEY = 'webReader_scraper_temp';
const bookData = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (!bookData || !bookData.chapters || bookData.chapters.length === 0) {
alert("다운로드할 챕터 데이터가 없습니다. 먼저 챕터를 저장해주세요.");
return;
}
// ★ 리더기의 '개별 책 복원' 형식과 완벽히 일치하도록 페이로드 구성
const payload = {
type: 'single_book',
book: bookData,
progress: {
chapter: 0,
page: 0,
tags: [], // 리더기에서 불러올 때 재분석 기능으로 태그를 달 수 있습니다.
memo: "스크랩을 통해 추가된 소설입니다.",
isFavorite: false,
isCompleted: false
}
};
// JSON 파일로 변환 및 다운로드 처리
const jsonString = JSON.stringify(payload, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 파일명에 사용할 수 없는 특수문자 제거
const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_');
a.download = `book_${safeFileName}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 다운로드 후 임시 데이터 정리
if (confirm("파일 다운로드가 완료되었습니다.\n다음 스크랩을 위해 임시 저장된 데이터를 초기화할까요?")) {
localStorage.removeItem(STORAGE_KEY);
console.log("임시 데이터가 초기화되었습니다.");
}
}
@@ -0,0 +1,38 @@
{
sequences: {
"www.nhmembers.co.kr/nhaob": [".btn-direct-menu"],
"multicon.co.kr/couponMall/order/info":[
{ target: () => document.querySelector(".point_inquiry").click(), wait: 5000 },
{ action: () => {
if(document.querySelector(".result_point").innerText.split("P")[0] > 0) {
location.href = "${NH_DOMAIN + APP_LOG}"
}
}, wait: 15000 },
],
"multicon.co.kr/couponMall/goodsDetail":[
{ target: () => document.querySelector(".grey_btn").click(), wait: 5000 }
],
"multicon.co.kr/couponMall/main":[
{ target: () => document.querySelector(".product").parentElement.click(), wait: 5000 }
],
"example.com/login": [
// 1. [순수 함수] 아이디 입력 (실행 후 바로 다음 스텝으로)
{ action: () => { document.querySelector(".userid-input").value = "test_id"; } },
// 2. [순수 함수 + 대기] 비밀번호 입력 후 1초 대기
{
action: () => { document.querySelector(".password-input").value = "1234!!"; },
wait: 1000
},
// 3. [클릭 액션] 로그인 버튼 클릭 (기존 방식들 모두 호환됨)
"#login-btn",
{ target: () => document.getElementById("confirm-modal"), wait: 2000 }
],
"mysite.com/survey": ["input[value='yes']", "button.next-step"],
},
blacklist: {
"any": ["logout", "delete", "remove", "exit","header__prev main_header"],
"runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"]
}
}
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
(function() {
var meta = document.querySelector('meta[name=viewport]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'viewport';
document.head.appendChild(meta);
}
meta.setAttribute('content', 'width=device-width, initial-scale=1.0');
})();
//(function() {
// var meta = document.querySelector('meta[name=viewport]');
// if (!meta) {
// meta = document.createElement('meta');
// meta.name = 'viewport';
// document.head.appendChild(meta);
// }
// meta.setAttribute('content', 'width=device-width, initial-scale=1.0');
//})();
@@ -13,7 +13,7 @@
},
"content_scripts": [
{
"run_at": "document_end",
"run_at": "document_start",
"matches": ["<all_urls>"],
"js": ["messaging.js","inject-viewport.js"]
}
@@ -1,5 +1,6 @@
function pubDateNumber(tdateTime) {
let date = new Date();
let dateTime = date.getTime();
@@ -55,6 +56,24 @@ port.onMessage.addListener(response => {
var type= response["type"];
switch (type) {
case "GO_TO_SUBTITLE_DETAIL": {
const detailUrl = response["url"];
location.href = detailUrl; // 상세 페이지로 이동
break;
}
case "SEARCH_SUBTITLE_CAT": {
const query = response["query"];
const searchUrl = `https://www.subtitlecat.com/index.php?search=${encodeURIComponent(query)}`;
location.href = searchUrl; // 검색 페이지로 이동
break;
}
case "SCROLL_TOP": {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
break;
case "SEEK_NEXT": // 5초 앞으로
{
let btn =
@@ -452,6 +471,7 @@ if(document.querySelector(".list-body") !== null) {
try {listBody = document.querySelector(".list-body");}catch (e) {}
getList(listBody.children)
}
if(document.querySelector("#novel_content") !== null){
removeSpecificGifs()
var title = null
@@ -470,6 +490,8 @@ if(document.querySelector("#novel_content") !== null){
);
}
}
if(document.querySelector("#html_encoder_div")) {
sendMessage(
{
@@ -554,6 +576,83 @@ function getList(children) {
}
}
function getList2(children) {
const contentsArray = [];
const maxCount = children.length;
// 반복문 내부에서 공통으로 사용될 변수 미리 선언
let bookTitle = "";
const viewTitleEl = document.getElementsByClassName('view-title')[0];
if (viewTitleEl) {
bookTitle = viewTitleEl.getElementsByTagName('span')[0].innerText;
}
for (let i = 0; i < maxCount; i++) {
const item = children[i];
// 1. 에피소드 번호 추출 (data-ep 속성 활용)
const chapterNum = item.getAttribute('data-ep');
// 2. URL 및 제목 요소 찾기
const linkAnchor = item.getElementsByTagName('a')[0];
if (!linkAnchor) continue;
let pageUrl = linkAnchor.href;
const chapterTitle = item.getElementsByClassName('ne-title')[0]?.innerText || "";
// 3 콘텐츠 타입 판별
const contentsType = location.hostname.includes("book") ? "book" :
location.hostname.includes("mana") ? "comics" :
location.hostname.includes("new") ? "webtoon" : "web";
// 4. URL 정제 (기존 로직 유지)
if (pageUrl && pageUrl.startsWith("http")) {
try {
const urlObj = new URL(pageUrl);
let paths = urlObj.pathname.split('/').filter(p => p !== "");
if (paths.length > 0) {
const last = paths[paths.length - 1];
// 마지막 요소가 숫자가 아니면 제거 (예: 파라미터나 기타 경로)
if (!/^\d+$/.test(last)) {
paths.pop();
}
}
pageUrl = "/" + paths.join('/');
} catch (e) {
console.error("URL parsing error:", e);
}
}
// 5. 데이터 객체 생성
const data = {
'chapterID': Number(chapterNum),
'chapterNum': Number(chapterNum),
'pathUrl': pageUrl,
'contentsType': contentsType,
'bookPageUrl': location.pathname,
'chapterTitle': chapterTitle.trim(),
'bookTitle': bookTitle,
};
contentsArray.push(data);
}
// 6. 결과 전송
if (contentsArray.length > 0) {
sendMessage({
type: "getListResult",
bookInfos: {
'contentsType': contentsArray[0].contentsType,
'bookTitle': bookTitle,
'bookPageUrl': location.pathname,
'pages': contentsArray,
}
});
}
}
function toonTitle(toon_title) {
return toon_title != null ? toon_title.innerText : null
}
@@ -631,6 +730,7 @@ function sendCookiesToNative() {
url: location.href
});
console.log("Cookies sent to native.");
scrollToEndAndExtract()
}
} catch (e) {
// 예외 무시
@@ -696,11 +796,86 @@ document.addEventListener('DOMContentLoaded', function () {
if (document.readyState === 'complete') {
sendCookiesToNative();
try{removeSpecificGifs();}catch(e){}
} else {
// 2. 아직 로딩 중이라면 window.onload(모든 리소스 로드 완료) 시점에 실행
window.addEventListener('load', sendCookiesToNative);
}
})
function extractSubtitleList() {
// 1. 현재 URL에서 'search' 파라미터(파일명) 추출
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('search');
if (location.host.includes("subtitlecat.com") && searchQuery) {
// tbody 안의 모든 행(tr)을 가져옴
const rows = Array.from(document.querySelectorAll('table.sub-table tbody tr'));
const subList = rows.map(row => {
const cols = row.querySelectorAll('td');
if (cols.length < 5) return null;
// 첫 번째 td 안에 <a> 태그와 (translated from ...) 텍스트가 있음
const linkEl = cols[0].querySelector('a');
const fullText = cols[0].innerText;
// 정규식을 사용하여 "translated from [언어]" 부분 추출
const langMatch = fullText.match(/\(translated from (.*?)\)/);
const originalLang = langMatch ? langMatch[1] : "Unknown";
return {
title: linkEl ? linkEl.innerText.trim() : "No Title",
downloadUrl: linkEl ? linkEl.href : null, // 상세 페이지 URL
lang: originalLang, // 원문 언어 정보
size: cols[2].querySelector('.sub-table__metric-value')?.innerText || "N/A",
downloads: cols[3].innerText.trim(),
originalFileName: decodeURIComponent(searchQuery) // 파일명 자동 매칭용
};
}).filter(item => item !== null && item.downloadUrl !== null);
if (subList.length > 0) {
sendMessage({
type: "SUBTITLE_LIST_RESULT",
subTitles: subList
});
}
}
}
function scrollToEndAndExtract() {
if (location.host.includes("subtitlecat.com")) {
const scrollStep = 800; // 한 번에 스크롤할 양
const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms)
function step() {
const prevScrollY = window.scrollY;
window.scrollBy(0, scrollStep);
console.log("ONSTEPS");
// 💡 0.5초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작
setTimeout(() => {
if (window.scrollY === prevScrollY ||
(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
if (location.host.includes("subtitlecat.com")) {
console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작");
extractSubtitleList(); // 기존에 정의한 추출 함수 호출
} else {
}
} else {
step(); // 아직 끝이 아니면 다음 스크롤 진행
}
}, scrollDelay);
}
step()
} else {
console.log(`is FAIL NOVEL ${location.href.includes("/novel/")}`);
}
}
const keywords = ["youtube", "mojeek"];
const url = location.href;
File diff suppressed because it is too large Load Diff
@@ -1,29 +1,203 @@
(function() {
console.log("Enhanced Scroll & Click Loop Started");
window.addEventListener('load',()=>{
const container = document.getElementById('container');
container.addEventListener('focus',(e)=> {
window.onpopstate = function(event){
try {
var historyUrl = new URL(document.referrer);
var referrerUrl = document.referrer;
console.log(history.state);
if(historyUrl.host != location.host || referrerUrl.includes('pntPointBankBill')){
// 현재 페이지 상태를 히스토리에 추가
history.pushState(null, null, location.href);
// 뒤로가기 버튼 누를 때 호출되는 이벤트 처리
// 홈으로 이동
location.href = "/nhaob/main/main.nh";
} else {
history.pushState("BACK", null, "/nhaob/main/main.nh");
console.log("did pushState");
const CLICK_INTERVAL = 1000;
const SCROLL_STEP = 300;
const selectors = [
'a', 'button', 'a.btn', 'input[type="button"]',
'input[type="submit"]', '[role="button"]'
];
// --- 설정 구역 ---
const config = {
// 1. 특정 페이지에서 순서대로 클릭해야 하는 경우
sequences: {
"www.nhmembers.co.kr/nhaob": [".btn-direct-menu"],
"example.com/login": [".userid-input", ".password-input", "#login-btn"],
"mysite.com/survey": ["input[value='yes']", "button.next-step"]
},
// 2. 특정 페이지에서 절대 클릭하면 안 되는 요소 (예: 로그아웃, 삭제 버튼)
blacklist: {
"any": ["logout", "delete", "remove", "exit","header__prev main_header"], // 모든 페이지 공통 키워드
"runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"] // 특정 페이지 전용
}
};
let sequenceStep = 0;
let lastUrl = "";
const mainLoop = setInterval(() => {
const currentUrl = window.location.href;
// 페이지가 바뀌면 순서(Step) 초기화
if (currentUrl !== lastUrl) {
sequenceStep = 0;
lastUrl = currentUrl;
}
// --- 로직 1: 특정 주소에서의 순차 클릭 처리 ---
const activeSequenceKey = Object.keys(config.sequences).find(url => currentUrl.includes(url));
if (activeSequenceKey) {
const steps = config.sequences[activeSequenceKey];
if (sequenceStep < steps.length) {
const targetSelector = steps[sequenceStep];
const target = document.querySelector(targetSelector);
if (target && isVisible(target)) {
console.log(`[Sequence] Clicking step ` + sequenceStep + ' : ', targetSelector);
target.click();
sequenceStep++; // 다음 단계로
return; // 순차 클릭을 했을 경우 랜덤 클릭 건너뜀
}
} catch (e) {
console.log(e);
}
}
})
container.focus();
})
window.focus()
document.body.focus()
// --- 로직 2: 일반 랜덤 클릭 (예외 처리 포함) ---
window.scrollBy(0, SCROLL_STEP);
const elements = document.querySelectorAll(selectors.join(','));
const visibleElements = Array.from(elements).filter(el => {
if (!isVisible(el)) return false;
// 현재 페이지에 적용할 블랙리스트 단어들 모으기
const pageBlacklist = config.blacklist["any"].concat(
Object.entries(config.blacklist)
.filter(([url]) => currentUrl.includes(url))
.flatMap(([_, tags]) => tags)
);
// 검사할 대상 문자열들을 배열로 만듦
const targetsToScan = [
el.innerText, // 버튼 위 텍스트
el.id, // ID
el.className.toString(), // 클래스명
el.getAttribute('alt'), // 이미지 대체 텍스트 (중요!)
el.getAttribute('title'), // 마우스 올리면 나오는 텍스트
el.getAttribute('aria-label') // 웹 접근성용 라벨
].map(val => (val || "").toLowerCase()); // 소문자로 통일해서 비교
// 블랙리스트 단어가 위 항목 중 하나라도 포함되어 있는지 확인
const isBlacklisted = pageBlacklist.some(term =>
targetsToScan.some(content => content.includes(term.toLowerCase()))
);
return !isBlacklisted;
});
if (visibleElements.length > 0) {
const randomIndex = Math.floor(Math.random() * visibleElements.length);
const target = visibleElements[randomIndex];
console.log('Target Clicked:', target.innerText || target.tagName);
try { target.click(); } catch (e) { console.error(e); }
}
if ((window.innerHeight + window.pageYOffset) >= document.documentElement.scrollHeight) {
window.scrollTo(0, 0);
}
}, CLICK_INTERVAL);
// 가시성 확인 함수
function isVisible(el) {
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 &&
rect.top >= 0 && rect.top <= window.innerHeight &&
window.getComputedStyle(el).visibility !== 'hidden' &&
window.getComputedStyle(el).display !== 'none';
}
window.autoCrawlerLoop = mainLoop;
})();
window.addEventListener('error', function(event) {
// 1. 일반적인 JS 문법/런타임 에러
if (event.message) {
const errorLog = {
type: 'JS_ERROR,
message: event.message,
source: event.filename,
line: event.lineno
};
console.warn(`Error: ` + event.message);
}
// 2. 리소스 로드 에러 (이미지, JS 파일이 없을 때)
else {
const target = event.target || event.srcElement;
console.warn('Resource Load Error:', target.src || target.href);
}
}, true);
function kkk(){
var sss = document.getElementsByTagName('meta');
for(i=0;i<sss.length;i++) {
if(sss[i].name.includes("viewport")) {
console.log(sss[i]);
sss[i].remove();
}
}
var meta = document.createElement('meta'); meta.setAttribute( 'name', 'viewport' ); meta.setAttribute( 'content', 'width = device-width, initial-scale = 1.0, minimum-scale = 1.0, maximum-scale = 4.0, user-scalable = yes, viewport-fit=cover' );
document.getElementsByTagName('head')[0].appendChild(meta);
var meta = document.createElement('meta'); meta.setAttribute( 'name', 'viewport' ); meta.setAttribute( 'content', 'width = device-width, initial-scale = 1.0, minimum-scale = 1.0, maximum-scale = 4.0, user-scalable = yes, viewport-fit=cover' );
document.getElementsByTagName('head')[0].appendChild(meta);
}
let scale = 1.0; // 현재 확대 비율
let startDistance = 0; // 터치 시작 시점의 두 손가락 거리
let baseScale = 1.0; // 터치 시작 시점의 기존 확대 비율
// 두 손가락 사이의 거리를 계산하는 함수 (피타고라스 정리 활용)
function getDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
const target = document.body;
// transform이 일어날 기준점을 왼쪽 상단(0,0)으로 고정
target.style.transformOrigin = "0 0";
target.style.transition = "transform 0.05s ease-out"; // 부드러운 애니메이션 효과
// 1. 터치 시작 (두 손가락이 닿았을 때)
window.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
// 기본 브라우저 줌인 동작 방지
e.preventDefault();
startDistance = getDistance(e.touches[0], e.touches[1]);
baseScale = scale; // 현재 배율을 기준으로 저장
}
}, { passive: false });
// 2. 터치 이동 (손가락을 벌리거나 오므릴 때)
window.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const currentDistance = getDistance(e.touches[0], e.touches[1]);
if (startDistance > 0) {
// 거리 비율만큼 배율 계산
const distanceRatio = currentDistance / startDistance;
let newScale = baseScale * distanceRatio;
// 최소 1배 ~ 최대 3배까지만 확대 제한 (원하는 대로 조절 가능)
newScale = Math.max(1.0, Math.min(newScale, 3.0));
scale = newScale;
target.style.transform = `scale(${scale})`;
console.log('Scale: ', + target.style.transform);
}
}
}, { passive: false });
// 3. 터치 종료 (손가락을 뗐을 때)
window.addEventListener('touchend', (e) => {
if (e.touches.length < 2) {
startDistance = 0; // 거리 초기화
}
});
@@ -1,22 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<title>Config Generator</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f4f7f8; padding: 20px; color: #333; }
.container { max-width: 900px; margin: 0 auto; }
h1, h2 { color: #2c3e50; }
.card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
<script type="text/javascript">
window.addEventListener('load', () => {
history.pushState(null, '', location.href);
window.addEventListener('popstate', function(event){
console.log("Got event");
});
console.log("addEventListener");
});
</script>
/* Drag & Drop 영역 */
.drop-zone { border: 2px dashed #3498db; border-radius: 8px; padding: 30px; text-align: center; background: #ebf5fb; color: #2980b9; font-weight: bold; cursor: pointer; transition: background 0.3s; margin-bottom: 20px; }
.drop-zone.dragover { background: #d6eaf8; border-color: #2980b9; }
.url-block { border: 1px solid #e1e8ed; padding: 15px; border-radius: 6px; margin-bottom: 15px; position: relative; background: #fafbfc; }
.remove-btn { position: absolute; right: 15px; top: 15px; background: #ff4757; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; }
.step-block { display: flex; gap: 10px; align-items: flex-start; background: #fff; border: 1px dashed #ccc; padding: 10px; border-radius: 4px; margin-top: 10px; }
.step-block select, .step-block input, .step-block textarea { padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
.step-block .code-input { flex-grow: 1; }
.step-block textarea { width: 100%; height: 60px; font-family: monospace; resize: vertical; }
.step-block .wait-input { width: 100px; }
.btn { background: #2ed573; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px; }
.btn-add { background: #1e90ff; }
.btn-generate { background: #3742fa; font-size: 16px; padding: 12px 20px; width: 48%; }
.btn-export { background: #f39c12; font-size: 16px; padding: 12px 20px; width: 48%; float: right; }
input[type="text"] { width: 300px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
pre { background: #2f3542; color: #f1f2f6; padding: 20px; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; }
.btn-row { display: flex; justify-content: space-between; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>봇 설정(Config) 생성기</h1>
<div id="dropZone" class="drop-zone">
📂 여기에 JSON 백업 파일을 드래그하거나 클릭하여 불러오세요.
<input type="file" id="fileInput" accept=".json" style="display: none;">
</div>
<div class="card">
<h2>1. Sequences (순차 처리)</h2>
<div id="sequences-container"></div>
<button class="btn btn-add" onclick="addSequenceUrl()">+ URL 추가</button>
</div>
<div class="card">
<h2>2. Blacklist (클릭 제외)</h2>
<div id="blacklist-container"></div>
<button class="btn btn-add" onclick="addBlacklistUrl()">+ 블랙리스트 URL 추가</button>
</div>
<div class="btn-row">
<button class="btn btn-generate" onclick="generateCode()">🚀 JS 코드 생성하기</button>
<button class="btn btn-export" onclick="exportJson()">💾 프로젝트 JSON 저장</button>
</div>
<div class="card" style="margin-top: 20px;">
<h2>결과 코드 (앱 삽입용)</h2>
<pre><code id="outputCode">// 여기에 자바스크립트 코드가 생성됩니다.</code></pre>
<button class="btn" onclick="copyCode()">코드 복사하기</button>
</div>
</div>
<script>
let sequenceCounter = 0;
let stepCounter = 0;
let blacklistCounter = 0;
// --- 초기 샘플 데이터 ---
const sampleData = {
sequences: {
"multicon.co.kr/couponMall/order/info": [
{ type: "target", value: "document.querySelector('.point_inquiry').click()", wait: "5000" },
{ type: "action", value: 'if(document.querySelector(".result_point").innerText.split("P")[0] > 0) {\n location.href = "${NH_DOMAIN + APP_LOG}";\n}', wait: "15000" }
]
},
blacklist: {
"any": ["logout", "delete", "remove", "exit", "header__prev main_header"],
"runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"]
}
};
// --- DOM 생성 로직 ---
function addSequenceUrl(url = '') {
const id = sequenceCounter++;
const container = document.getElementById('sequences-container');
const html = `
<div class="url-block" id="seq-${id}">
<button class="remove-btn" onclick="document.getElementById('seq-${id}').remove()">삭제</button>
<label><strong>URL 키워드:</strong></label>
<input type="text" class="seq-url" placeholder="예: example.com/login" value="${url}">
<div id="steps-container-${id}" style="margin-top: 15px;"></div>
<button class="btn btn-add" style="background:#57606f;" onclick="addStep(${id})">+ 스텝(Step) 추가</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
return id;
}
function addStep(urlId, stepData = { type: 'string', value: '', wait: '' }) {
const sId = stepCounter++;
const container = document.getElementById(`steps-container-${urlId}`);
const html = `
<div class="step-block" id="step-${sId}">
<select class="step-type" onchange="changeStepType(${sId})">
<option value="string" ${stepData.type === 'string' ? 'selected' : ''}>기본 클릭 (문자열)</option>
<option value="target" ${stepData.type === 'target' ? 'selected' : ''}>동적 타겟 (Target)</option>
<option value="action" ${stepData.type === 'action' ? 'selected' : ''}>순수 함수 (Action)</option>
</select>
<div id="step-input-area-${sId}" style="flex-grow: 1; display: flex; gap: 10px;"></div>
<button class="remove-btn" style="position:static;" onclick="document.getElementById('step-${sId}').remove()">X</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
changeStepType(sId, stepData.value, stepData.wait);
}
function changeStepType(sId, defaultValue = '', defaultWait = '') {
const type = document.querySelector(`#step-${sId} .step-type`).value;
const area = document.getElementById(`step-input-area-${sId}`);
// 기존 값이 있다면 보존, 없다면 인자값 사용
const existingInput = area.querySelector('.seq-val');
const existingWait = area.querySelector('.wait-input');
const val = existingInput ? existingInput.value : defaultValue;
const wait = existingWait ? existingWait.value : defaultWait;
if (type === 'string') {
area.innerHTML = `<input type="text" class="code-input seq-val" placeholder='CSS 선택자 (예: .btn-login)' value="${val}">`;
} else if (type === 'target') {
area.innerHTML = `
<div style="flex-grow:1;">
<span style="font-family:monospace; font-size:12px;">() => </span>
<input type="text" class="code-input seq-val" style="width: calc(100% - 40px);" placeholder="document.querySelector('.btn')" value="${val}">
</div>
<input type="number" class="wait-input" placeholder="Wait(ms)" value="${wait}">
`;
} else if (type === 'action') {
area.innerHTML = `
<textarea class="code-input seq-val" placeholder="if(condition) { location.href='...'; }">${val}</textarea>
<input type="number" class="wait-input" placeholder="Wait(ms)" value="${wait}">
`;
}
}
function addBlacklistUrl(url = '', tagsArray = []) {
const id = blacklistCounter++;
const container = document.getElementById('blacklist-container');
const tags = tagsArray.join(', ');
const html = `
<div class="url-block" id="bl-${id}">
<button class="remove-btn" onclick="document.getElementById('bl-${id}').remove()">삭제</button>
<label><strong>URL 키워드:</strong></label>
<input type="text" class="bl-url" placeholder="예: any 또는 특정 URL" value="${url}">
<div style="margin-top: 10px;">
<label><strong>제외 키워드:</strong> (콤마 ',' 로 구분)</label><br>
<input type="text" class="bl-tags" style="width: 100%; margin-top:5px;" placeholder="logout, delete" value="${tags}">
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// --- JSON Load & Export 로직 ---
function loadFromJson(data) {
document.getElementById('sequences-container').innerHTML = '';
document.getElementById('blacklist-container').innerHTML = '';
if (data.sequences) {
for (const [url, steps] of Object.entries(data.sequences)) {
const urlId = addSequenceUrl(url);
steps.forEach(step => addStep(urlId, step));
}
}
if (data.blacklist) {
for (const [url, tags] of Object.entries(data.blacklist)) {
addBlacklistUrl(url, tags);
}
}
generateCode();
}
function extractCurrentData() {
const data = { sequences: {}, blacklist: {} };
document.querySelectorAll('[id^="seq-"]').forEach(urlBlock => {
const url = urlBlock.querySelector('.seq-url').value.trim();
if (!url) return;
data.sequences[url] = [];
urlBlock.querySelectorAll('.step-block').forEach(stepBlock => {
const type = stepBlock.querySelector('.step-type').value;
const val = stepBlock.querySelector('.seq-val').value.trim();
const waitInput = stepBlock.querySelector('.wait-input');
const wait = waitInput && waitInput.value ? waitInput.value : "";
if (val) data.sequences[url].push({ type, value: val, wait });
});
});
document.querySelectorAll('[id^="bl-"]').forEach(blBlock => {
const url = blBlock.querySelector('.bl-url').value.trim();
const tags = blBlock.querySelector('.bl-tags').value.split(',').map(t => t.trim()).filter(t => t);
if (url && tags.length > 0) data.blacklist[url] = tags;
});
return data;
}
function exportJson() {
const data = extractCurrentData();
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "config_project.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
// --- 자바스크립트 코드(앱용) 생성 로직 ---
function generateCode() {
const data = extractCurrentData();
let code = "const config = {\n sequences: {\n";
for (const [url, steps] of Object.entries(data.sequences)) {
code += ` "${url}": [\n`;
steps.forEach(step => {
const waitStr = step.wait ? `, wait: ${step.wait}` : '';
if (step.type === 'string') {
code += ` "${step.value}",\n`;
} else if (step.type === 'target') {
code += ` { target: () => ${step.value}${waitStr} },\n`;
} else if (step.type === 'action') {
const formattedVal = step.value.includes('\n') ? `\n ${step.value.replace(/\n/g, '\n ')}\n ` : step.value;
code += ` { action: () => { ${formattedVal} }${waitStr} },\n`;
}
});
code += ` ],\n`;
}
code += " },\n blacklist: {\n";
for (const [url, tags] of Object.entries(data.blacklist)) {
const tagsStr = tags.map(t => `"${t}"`).join(', ');
code += ` "${url}": [${tagsStr}],\n`;
}
code += " }\n};";
document.getElementById('outputCode').textContent = code;
}
function copyCode() {
const text = document.getElementById('outputCode').textContent;
navigator.clipboard.writeText(text).then(() => alert('코드가 복사되었습니다!'));
}
// --- Drag & Drop 파일 업로드 로직 ---
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleFile(e.target.files[0]);
});
function handleFile(file) {
if (!file.name.endsWith('.json')) {
alert('JSON 파일만 업로드 가능합니다.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
loadFromJson(json);
alert('데이터를 성공적으로 불러왔습니다.');
} catch (err) {
alert('JSON 파싱에 실패했습니다: ' + err.message);
}
};
reader.readAsText(file);
}
// --- 초기 실행 ---
window.onload = () => {
loadFromJson(sampleData);
};
</script>
</body>
</html>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -39,6 +39,9 @@ add_library(native_renderer SHARED
Renderer.cpp
Preloader.cpp
MediaAsset.cpp
native_player.cpp
PlayerEngine.cpp
sonic.c
)
@@ -54,7 +57,9 @@ target_link_libraries(native_renderer
avutil
swscale
swresample
jnigraphics
swresample # 💡 !
aaudio # 💡 !
#
${log-lib}
${android-lib}
+74
View File
@@ -0,0 +1,74 @@
//
// Created by JIBUM HAN on 2026. 4. 11..
//
#pragma once
#include <queue>
#include <mutex>
#include <condition_variable>
extern "C" {
#include <libavcodec/avcodec.h>
}
class PacketQueue {
private:
std::queue<AVPacket*> queue_;
std::mutex mutex_;
std::condition_variable cond_;
int sizeBytes_ = 0;
bool abortRequest_ = false; // 플레이어 종료 시 대기 중인 스레드를 깨우기 위함
public:
void abort() {
std::lock_guard<std::mutex> lock(mutex_);
abortRequest_ = true;
cond_.notify_all();
}
void start() {
std::lock_guard<std::mutex> lock(mutex_);
abortRequest_ = false;
}
bool push(AVPacket* pkt) {
std::lock_guard<std::mutex> lock(mutex_);
if (abortRequest_) return false;
// 패킷 복사 (메모리 소유권을 큐로 넘김)
AVPacket* pkt1 = av_packet_alloc();
av_packet_move_ref(pkt1, pkt);
queue_.push(pkt1);
sizeBytes_ += pkt1->size;
cond_.notify_one(); // 데이터를 기다리며 자고 있는 디코딩 스레드를 깨움
return true;
}
// 데이터를 꺼낼 때 큐가 비어있으면 block(대기) 상태가 됨
AVPacket* pop() {
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this]() { return !queue_.empty() || abortRequest_; });
if (abortRequest_ || queue_.empty()) return nullptr;
AVPacket* pkt = queue_.front();
queue_.pop();
sizeBytes_ -= pkt->size;
return pkt;
}
void flush() {
std::lock_guard<std::mutex> lock(mutex_);
while (!queue_.empty()) {
AVPacket* pkt = queue_.front();
queue_.pop();
av_packet_free(&pkt);
}
sizeBytes_ = 0;
}
int getSizeBytes() {
std::lock_guard<std::mutex> lock(mutex_);
return sizeBytes_;
}
};
+627
View File
@@ -0,0 +1,627 @@
#include "PlayerEngine.h"
#include <android/log.h>
#include <chrono>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <errno.h>
#define LOG_TAG "NativePlayerEngine"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
const int MIN_BUFFER_SIZE = 2 * 1024 * 1024;
static int custom_read_packet(void *opaque, uint8_t *buf, int buf_size) {
int fd = (int)(intptr_t)opaque;
int ret = read(fd, buf, buf_size);
if (ret == 0) return AVERROR_EOF;
if (ret < 0) return AVERROR(errno);
return ret;
}
static int64_t custom_seek_packet(void *opaque, int64_t offset, int whence) {
int fd = (int)(intptr_t)opaque;
if (whence == AVSEEK_SIZE) {
struct stat st;
if (fstat(fd, &st) == 0) return st.st_size;
return AVERROR(errno);
}
whence &= ~AVSEEK_FORCE;
int64_t ret = lseek(fd, offset, whence);
return ret < 0 ? AVERROR(errno) : ret;
}
PlayerEngine::PlayerEngine(JavaVM* vm, jobject listenerObj) : jvm_(vm) {
JNIEnv* env;
jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
listenerObj_ = env->NewGlobalRef(listenerObj);
jclass clazz = env->GetObjectClass(listenerObj_);
auto safeGetMethod = [&](const char* name, const char* sig) -> jmethodID {
jmethodID id = env->GetMethodID(clazz, name, sig);
if (env->ExceptionCheck()) {
env->ExceptionClear();
LOGE("❌ Failed to find method: %s %s", name, sig);
return nullptr;
}
return id;
};
subtitleMethodId_ = safeGetMethod("onSubtitleTextDecoded", "(Ljava/lang/String;)V");
videoSizeMethodId_ = safeGetMethod("onVideoSizeChanged", "(II)V");
preparedMethodId_ = safeGetMethod("onNativePrepared", "()V");
errorMethodId_ = safeGetMethod("onNativeError", "(ILjava/lang/String;)V");
}
PlayerEngine::~PlayerEngine() {
stop();
if (prepareThread_.joinable()) prepareThread_.join(); // 준비 스레드 대기
if (listenerObj_) {
JNIEnv* env; jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
env->DeleteGlobalRef(listenerObj_);
}
}
void PlayerEngine::setDataSource(int videoFd, int subtitleFd) {
videoFd_ = videoFd;
subtitleFd_ = subtitleFd;
}
void PlayerEngine::prepareAsync() {
if (isPrepared_ || prepareThread_.joinable()) return;
prepareThread_ = std::thread(&PlayerEngine::prepareInternal, this);
}
void PlayerEngine::prepareInternal() {
JNIEnv* env;
jvm_->AttachCurrentThread(&env, nullptr);
auto sendError = [&](int code, const char* msg) {
LOGE("Prepare Error [%d]: %s", code, msg);
if (errorMethodId_) {
jstring jMsg = env->NewStringUTF(msg);
env->CallVoidMethod(listenerObj_, errorMethodId_, code, jMsg);
env->DeleteLocalRef(jMsg);
}
jvm_->DetachCurrentThread();
};
int avio_buffer_size = 32768;
uint8_t* avio_buffer = (uint8_t*)av_malloc(avio_buffer_size);
AVIOContext* avio_ctx = avio_alloc_context(avio_buffer, avio_buffer_size, 0, (void*)(intptr_t)videoFd_, custom_read_packet, nullptr, custom_seek_packet);
fmt_ctx_ = avformat_alloc_context();
fmt_ctx_->pb = avio_ctx;
if (avformat_open_input(&fmt_ctx_, nullptr, nullptr, nullptr) < 0) {
return sendError(-1001, "Failed to open video file. Invalid FD or Format.");
}
if (avformat_find_stream_info(fmt_ctx_, nullptr) < 0) {
return sendError(-1002, "Failed to retrieve stream information.");
}
subtitle_tracks_info_ = "";
for (unsigned int i = 0; i < fmt_ctx_->nb_streams; i++) {
auto type = fmt_ctx_->streams[i]->codecpar->codec_type;
if (type == AVMEDIA_TYPE_VIDEO && video_stream_idx_ < 0) video_stream_idx_ = i;
else if (type == AVMEDIA_TYPE_AUDIO && audio_stream_idx_ < 0) audio_stream_idx_ = i;
else if (type == AVMEDIA_TYPE_SUBTITLE) {
AVDictionaryEntry *lang = av_dict_get(fmt_ctx_->streams[i]->metadata, "language", nullptr, 0);
AVDictionaryEntry *title = av_dict_get(fmt_ctx_->streams[i]->metadata, "title", nullptr, 0);
std::string trackName = "Track " + std::to_string(i);
if (title) trackName = title->value;
else if (lang) trackName = lang->value;
subtitle_tracks_info_ += std::to_string(i) + ":" + trackName + ",";
}
}
if (video_stream_idx_ >= 0) {
auto par = fmt_ctx_->streams[video_stream_idx_]->codecpar;
auto codec = avcodec_find_decoder(par->codec_id);
video_codec_ctx_ = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(video_codec_ctx_, par);
if (avcodec_open2(video_codec_ctx_, codec, nullptr) == 0 && videoSizeMethodId_) {
env->CallVoidMethod(listenerObj_, videoSizeMethodId_, video_codec_ctx_->width, video_codec_ctx_->height);
}
}
if (audio_stream_idx_ >= 0) {
auto par = fmt_ctx_->streams[audio_stream_idx_]->codecpar;
auto codec = avcodec_find_decoder(par->codec_id);
audio_codec_ctx_ = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(audio_codec_ctx_, par);
if (avcodec_open2(audio_codec_ctx_, codec, nullptr) == 0) {
AVChannelLayout out_ch; av_channel_layout_default(&out_ch, 2);
swr_alloc_set_opts2(&swr_ctx_, &out_ch, AV_SAMPLE_FMT_S16, 48000, &audio_codec_ctx_->ch_layout, audio_codec_ctx_->sample_fmt, audio_codec_ctx_->sample_rate, 0, nullptr);
swr_init(swr_ctx_);
AAudioStreamBuilder* builder;
AAudio_createStreamBuilder(&builder);
AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16);
AAudioStreamBuilder_setChannelCount(builder, 2);
AAudioStreamBuilder_setSampleRate(builder, 48000);
AAudioStreamBuilder_openStream(builder, &audio_stream_);
// 💡 1. 버퍼 크기를 가용 가능한 최대치로 늘려서 비디오 렌더링 딜레이에 대비합니다.
AAudioStream_setBufferSizeInFrames(audio_stream_, AAudioStream_getBufferCapacityInFrames(audio_stream_));
// 🚀 Sonic 초기화 추가 (Sample Rate 48000, Channels 2)
sonic_stream_ = sonicCreateStream(48000, 2);
sonicSetSpeed(sonic_stream_, 1.0f); // 초기 배속 1.0
// 💡 2. 여기서 호출하던 AAudioStream_requestStart(audio_stream_); 를 삭제합니다! (대기 상태로 둠)
AAudioStreamBuilder_delete(builder);
}
}
isPrepared_ = true;
if (preparedMethodId_) env->CallVoidMethod(listenerObj_, preparedMethodId_);
jvm_->DetachCurrentThread();
}
void PlayerEngine::play(ANativeWindow* window) {
if (!isPrepared_) return;
if (isPlaying_) return;
window_ = window;
ANativeWindow_acquire(window_);
isPlaying_ = true;
abortRequest_ = false;
videoQueue_.start();
audioQueue_.start();
if (audio_stream_) AAudioStream_requestStart(audio_stream_);
// 🚀 3개의 스레드 동시 가동
readThread_ = std::thread(&PlayerEngine::readThreadLoop, this);
videoThread_ = std::thread(&PlayerEngine::videoThreadLoop, this);
audioThread_ = std::thread(&PlayerEngine::audioThreadLoop, this);
}
void PlayerEngine::pause() {
isPaused_ = true;
// 💡 영상이 일시정지되면 오디오 버퍼도 소모되지 않게 멈춰줍니다.
if (audio_stream_) AAudioStream_requestPause(audio_stream_);
}
void PlayerEngine::stop() {
if (!isPlaying_) return;
isPlaying_ = false;
abortRequest_ = true;
// 큐를 깨워서 멈춰있는 스레드들을 탈출시킴
videoQueue_.abort();
audioQueue_.abort();
if (readThread_.joinable()) readThread_.join();
if (videoThread_.joinable()) videoThread_.join();
if (audioThread_.joinable()) audioThread_.join();
videoQueue_.flush();
audioQueue_.flush();
if (sonic_stream_) {
sonicDestroyStream(sonic_stream_);
sonic_stream_ = nullptr;
}
if (window_) { ANativeWindow_release(window_); window_ = nullptr; }
}
void PlayerEngine::seekBy(double seconds) {
seekTargetOffset_ = seconds;
seekReq_ = true;
}
void PlayerEngine::setSpeed(float speed) {
float oldSpeed = playbackSpeed_.load();
playbackSpeed_ = speed > 0.0f ? speed : 1.0f;
// 💡 배속이 종료(1.0으로 복귀)될 때만 특별 처리를 위해 플래그 설정
if (oldSpeed != 1.0f && speed == 1.0f) {
seekReq_ = true; // 강제 Flush를 위해 Seek 로직을 재활용합니다.
isAbsoluteSeek_ = true;
seekTargetSec_ = currentPosSec_; // 현재 비디오 위치로 오디오를 강제 고정
}
if (sonic_stream_) {
sonicSetSpeed(sonic_stream_, playbackSpeed_.load());
}
}
void PlayerEngine::sendSubtitleToKotlin(const char* text) {
if (!jvm_ || !listenerObj_ || !subtitleMethodId_ || !text) return;
JNIEnv* env; bool attached = false;
if (jvm_->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) { jvm_->AttachCurrentThread(&env, nullptr); attached = true; }
jstring jText = env->NewStringUTF(text);
env->CallVoidMethod(listenerObj_, subtitleMethodId_, jText);
env->DeleteLocalRef(jText);
if (attached) jvm_->DetachCurrentThread();
}
double PlayerEngine::getDuration() const {
if (fmt_ctx_ && fmt_ctx_->duration != AV_NOPTS_VALUE) {
return (double)fmt_ctx_->duration / AV_TIME_BASE;
}
return 0.0;
}
void PlayerEngine::seekTo(double seconds) {
seekTargetSec_ = seconds;
isAbsoluteSeek_ = true;
seekReq_ = true;
}
//void PlayerEngine::renderLoop() {
// LOGI("🚀 Player render loop started (AV Sync & Performance Optimized)");
//
// AVFrame* frame = av_frame_alloc();
// AVPacket* pkt = av_packet_alloc();
// int last_win_w = 0, last_win_h = 0;
//
// // 1. 영상의 실제 FPS 정보를 기반으로 프레임당 지연 시간(마이크로초) 계산
// double fps = av_q2d(fmt_ctx_->streams[video_stream_idx_]->avg_frame_rate);
// if (fps <= 0) fps = 23.976; // 정보가 없을 경우 영화 표준 프레임 레이트 적용
// int64_t frame_delay_us = (int64_t)(1000000.0 / fps);
//
// while (isPlaying_) {
// // 일시정지 처리
// if (isPaused_) {
// std::this_thread::sleep_for(std::chrono::milliseconds(20));
// continue;
// }
//
// // Seek(탐색) 요청 처리
// if (seekReq_) {
// int64_t seek_target;
// if (isAbsoluteSeek_) {
// seek_target = (int64_t)(seekTargetSec_ * AV_TIME_BASE);
// } else {
// seek_target = (int64_t)((currentPosSec_ + seekTargetOffset_) * AV_TIME_BASE);
// }
//
// av_seek_frame(fmt_ctx_, -1, seek_target, AVSEEK_FLAG_BACKWARD);
//
// if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_);
// if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_);
//
// if (audio_stream_) {
// AAudioStream_requestPause(audio_stream_);
// AAudioStream_requestFlush(audio_stream_);
// AAudioStream_requestStart(audio_stream_);
// }
// seekReq_ = false;
// isAbsoluteSeek_ = false; // 플래그 초기화
// }
//
// float currentSpeed = playbackSpeed_.load();
//
// // 패킷 읽기
// if (av_read_frame(fmt_ctx_, pkt) < 0) break;
//
// // --- 비디오 스트림 처리 ---
// if (pkt->stream_index == video_stream_idx_) {
// // 💡 프레임 렌더링 시작 시간 기록
// auto frame_start = std::chrono::high_resolution_clock::now();
//
// avcodec_send_packet(video_codec_ctx_, pkt);
// while (avcodec_receive_frame(video_codec_ctx_, frame) == 0) {
// // 현재 재생 시간(초) 업데이트
// currentPosSec_ = frame->pts * av_q2d(fmt_ctx_->streams[video_stream_idx_]->time_base);
//
// if (window_) {
// int w = ANativeWindow_getWidth(window_), h = ANativeWindow_getHeight(window_);
// if (!sws_ctx_ || w != last_win_w || h != last_win_h) {
// if (sws_ctx_) sws_freeContext(sws_ctx_);
// sws_ctx_ = sws_getContext(frame->width, frame->height, video_codec_ctx_->pix_fmt, w, h, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr);
// last_win_w = w; last_win_h = h;
// }
//
// ANativeWindow_Buffer buffer;
// if (ANativeWindow_lock(window_, &buffer, nullptr) == 0) {
// uint8_t* dst_data[4] = { (uint8_t*)buffer.bits, nullptr, nullptr, nullptr };
// int dst_line[4] = { buffer.stride * 4, 0, 0, 0 };
// if (sws_ctx_) sws_scale(sws_ctx_, frame->data, frame->linesize, 0, frame->height, dst_data, dst_line);
// ANativeWindow_unlockAndPost(window_);
// }
// }
//
// // 💡 [핵심 동기화] 연산에 소요된 시간을 제외한 나머지 시간만 대기
// auto frame_end = std::chrono::high_resolution_clock::now();
// int64_t actual_render_time = std::chrono::duration_cast<std::chrono::microseconds>(frame_end - frame_start).count();
//
// // 배속(currentSpeed)을 고려한 대기 시간 계산
// int64_t wait_time = (int64_t)((frame_delay_us / currentSpeed) - actual_render_time);
//
// if (wait_time > 0) {
// std::this_thread::sleep_for(std::chrono::microseconds(wait_time));
// }
// }
// }
// // --- 오디오 스트림 처리 ---
// else if (pkt->stream_index == audio_stream_idx_) {
// // 정속 재생(1.0)일 때만 오디오 재생 (오디오 배속은 로직이 복잡하므로 정속 위주 최적화)
// if (currentSpeed == 1.0f) {
// avcodec_send_packet(audio_codec_ctx_, pkt);
// while (avcodec_receive_frame(audio_codec_ctx_, frame) == 0) {
// if (swr_ctx_ && audio_stream_) {
// int out_samples = swr_get_out_samples(swr_ctx_, frame->nb_samples);
// uint8_t* out_buf = (uint8_t*)malloc(out_samples * 4);
// int converted_samples = swr_convert(swr_ctx_, &out_buf, out_samples, (const uint8_t**)frame->data, frame->nb_samples);
//
// if (converted_samples > 0) {
// // 💡 [수정] 타임아웃을 0으로 설정하여 오디오 버퍼가 꽉 차도 비디오 스레드가 멈추지 않게 함
// AAudioStream_write(audio_stream_, out_buf, converted_samples, 0);
// }
// free(out_buf);
// }
// }
// }
// }
// // --- 자막 스트림 처리 ---
// else if (pkt->stream_index == current_sub_stream_idx_) {
// AVSubtitle sub; int got_sub = 0;
// avcodec_decode_subtitle2(sub_codec_ctx_, &sub, &got_sub, pkt);
// if (got_sub) {
// for (unsigned int i = 0; i < sub.num_rects; i++) {
// if (sub.rects[i]->type == SUBTITLE_TEXT && sub.rects[i]->text) sendSubtitleToKotlin(sub.rects[i]->text);
// else if (sub.rects[i]->type == SUBTITLE_ASS && sub.rects[i]->ass) sendSubtitleToKotlin(sub.rects[i]->ass);
// }
// avsubtitle_free(&sub);
// }
// }
// av_packet_unref(pkt);
// }
//
// // 자원 해제 로직
// if (audio_stream_) {
// AAudioStream_requestStop(audio_stream_);
// AAudioStream_close(audio_stream_);
// audio_stream_ = nullptr;
// }
// av_frame_free(&frame);
// av_packet_free(&pkt);
// if (swr_ctx_) swr_free(&swr_ctx_);
// if (sws_ctx_) sws_freeContext(sws_ctx_);
// if (video_codec_ctx_) avcodec_free_context(&video_codec_ctx_);
// if (audio_codec_ctx_) avcodec_free_context(&audio_codec_ctx_);
// if (fmt_ctx_) avformat_close_input(&fmt_ctx_);
//
// LOGI("🏁 Player loop finished gracefully.");
//}
void PlayerEngine::readThreadLoop() {
AVPacket* pkt = av_packet_alloc();
const int MAX_QUEUE_SIZE = 15 * 1024 * 1024;
while (!abortRequest_) {
// 🚀 [탐색(Seek) 처리 로직]
if (seekReq_) {
// 1. 목표 시간 계산
double targetSec = isAbsoluteSeek_ ? seekTargetSec_.load() : (currentPosSec_ + seekTargetOffset_.load());
int64_t seek_target = (int64_t)(targetSec * AV_TIME_BASE);
// 2. FFmpeg 탐색 수행
av_seek_frame(fmt_ctx_, -1, seek_target, AVSEEK_FLAG_BACKWARD);
// 3. 💡 [핵심] 기존에 큐에 쌓여있던 옛날 패킷들을 전부 폭파!
videoQueue_.flush();
audioQueue_.flush();
// 4. 코덱 내부에 남아있는 옛날 프레임 찌꺼기 초기화
// if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_);
// videoCodecFlushReq_ = false;
// if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_);
// // 이미 비웠으므로 false
// audioCodecFlushReq_ = false;
// if (sonic_stream_) {
// sonicFlushStream(sonic_stream_);
// }
//
// // 5. 오디오 장치 초기화 (틱 잡음 방지)
// if (audio_stream_) {
// AAudioStream_requestPause(audio_stream_);
// AAudioStream_requestFlush(audio_stream_);
// AAudioStream_requestStart(audio_stream_);
// }
videoCodecFlushReq_ = true;
audioCodecFlushReq_ = true;
// 6. 💡 [매우 중요] 마스터 시계 강제 업데이트
// 이거 안 하면 비디오 스레드가 옛날 오디오 시간과 비교하느라 화면이 굳어버립니다.
audioClock_ = targetSec;
currentPosSec_ = targetSec;
seekReq_ = false;
isAbsoluteSeek_ = false;
// 초기화가 끝났으니 다시 패킷을 처음부터 읽으러 올라갑니다.
continue;
}
// 일시정지 상태거나 큐가 너무 꽉 찼다면 잠시 대기
if (isPaused_ || (videoQueue_.getSizeBytes() + audioQueue_.getSizeBytes() > MAX_QUEUE_SIZE)) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
if (av_read_frame(fmt_ctx_, pkt) < 0) {
// EOF (파일 끝) 도달 시 대기
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
if (pkt->stream_index == video_stream_idx_) {
videoQueue_.push(pkt);
} else if (pkt->stream_index == audio_stream_idx_) {
audioQueue_.push(pkt);
}
av_packet_unref(pkt); // 큐 안에서 복사했으므로 원본은 비움
}
av_packet_free(&pkt);
}
void PlayerEngine::audioThreadLoop() {
AVFrame* frame = av_frame_alloc();
const int MIN_BUFFER_SIZE = 2 * 1024 * 1024;
bool isBuffering = true;
const int MAX_SONIC_SAMPLES = 8192;
short* sonic_out_buf = new short[MAX_SONIC_SAMPLES * 2];
while (!abortRequest_) {
if (isPaused_) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
int currentAudioSize = audioQueue_.getSizeBytes();
int currentVideoSize = videoQueue_.getSizeBytes();
int totalQueueSize = currentAudioSize + currentVideoSize;
// 💡 배속 중이든 아니든 이제 무조건 정상적인 버퍼링 로직을 탑니다.
if (totalQueueSize < MIN_BUFFER_SIZE / 2) isBuffering = true;
else if (totalQueueSize >= MIN_BUFFER_SIZE) isBuffering = false;
if (isBuffering && !abortRequest_) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
AVPacket* pkt = audioQueue_.pop();
if (!pkt) break;
// 💡 [추가] 오디오도 스스로 코덱과 Sonic 버퍼를 비웁니다.
if (audioCodecFlushReq_) {
avcodec_flush_buffers(audio_codec_ctx_);
if (sonic_stream_) {
sonicFlushStream(sonic_stream_);
}
// AAudio의 틱 잡음을 막기 위해 재생 중인 버퍼도 비워줍니다.
if (audio_stream_) {
AAudioStream_requestPause(audio_stream_);
AAudioStream_requestFlush(audio_stream_);
AAudioStream_requestStart(audio_stream_);
}
audioCodecFlushReq_ = false;
}
avcodec_send_packet(audio_codec_ctx_, pkt);
while (avcodec_receive_frame(audio_codec_ctx_, frame) == 0) {
// 마스터 시계 갱신
if (frame->pts != AV_NOPTS_VALUE) {
audioClock_ = frame->pts * av_q2d(fmt_ctx_->streams[audio_stream_idx_]->time_base);
}
if (swr_ctx_ && audio_stream_) {
int out_samples = swr_get_out_samples(swr_ctx_, frame->nb_samples);
uint8_t* out_buf = (uint8_t*)malloc(out_samples * 4); // S16 2채널 = 4바이트
// 1. FFmpeg으로 PCM 변환 (정배속 데이터)
int converted = swr_convert(swr_ctx_, &out_buf, out_samples, (const uint8_t**)frame->data, frame->nb_samples);
if (converted > 0 && sonic_stream_ && !abortRequest_) {
// 🚀 2. 변환된 데이터를 Sonic 스트림에 밀어넣습니다 (입력)
sonicWriteShortToStream(sonic_stream_, (short*)out_buf, converted);
// 🚀 3. Sonic 스트림에서 배속 처리 완료된 데이터를 뽑아냅니다 (출력)
int samplesRead;
do {
// 가공된 데이터 꺼내기 (반환값: 꺼낸 샘플 수)
samplesRead = sonicReadShortFromStream(sonic_stream_, sonic_out_buf, MAX_SONIC_SAMPLES);
if (samplesRead > 0 && !abortRequest_) {
// 4. 가공된 최종 데이터를 사운드 카드(AAudio)에 씁니다!
int32_t framesLeft = samplesRead;
short* currentAAudioBuf = sonic_out_buf;
while (framesLeft > 0 && !abortRequest_) {
int32_t framesWritten = AAudioStream_write(audio_stream_, currentAAudioBuf, framesLeft, 1000000000);
if (seekReq_) break;
if (framesWritten < 0) break;
framesLeft -= framesWritten;
currentAAudioBuf += framesWritten * 2; // 2채널 전진
}
}
} while (samplesRead > 0); // Sonic에 꺼낼 데이터가 없을 때까지 반복
}
free(out_buf);
}
}
av_packet_free(&pkt);
}
delete[] sonic_out_buf;
av_frame_free(&frame);
}
void PlayerEngine::videoThreadLoop() {
AVFrame* frame = av_frame_alloc();
int last_win_w = 0, last_win_h = 0;
while (!abortRequest_) {
if (isPaused_) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
AVPacket* pkt = videoQueue_.pop();
if (!pkt) break;
// 💡 [추가] 패킷을 밀어 넣기 직전에 청소 명령이 있었는지 확인!
if (videoCodecFlushReq_) {
avcodec_flush_buffers(video_codec_ctx_);
videoCodecFlushReq_ = false;
}
avcodec_send_packet(video_codec_ctx_, pkt);
while (avcodec_receive_frame(video_codec_ctx_, frame) == 0) {
double videoPtsSec = frame->pts * av_q2d(fmt_ctx_->streams[video_stream_idx_]->time_base);
currentPosSec_ = videoPtsSec; // UI 바 업데이트용
// 💡 위대한 귀환: 비디오는 무조건 오디오 시계만 쳐다봅니다!
double delaySec = videoPtsSec - audioClock_.load();
float currentSpeed = playbackSpeed_.load();
// 🚀 [A/V Sync & 프레임 드랍 로직]
if (delaySec > 0.01) {
// 비디오가 미래다 -> 남은 시간만큼 재운다
double waitTime = delaySec / currentSpeed;
std::this_thread::sleep_for(std::chrono::microseconds((int64_t)(waitTime * 1000000.0)));
}
else if (delaySec < -0.05) {
// 비디오가 과거다 -> 그리지 않고 가차없이 버린다 (이것 덕분에 4배속 화면 렌더링 부하가 해결됨!)
continue;
}
// --- 렌더링 통과 ---
if (window_) {
int w = ANativeWindow_getWidth(window_), h = ANativeWindow_getHeight(window_);
if (!sws_ctx_ || w != last_win_w || h != last_win_h) {
if (sws_ctx_) sws_freeContext(sws_ctx_);
sws_ctx_ = sws_getContext(frame->width, frame->height, video_codec_ctx_->pix_fmt, w, h, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr);
last_win_w = w; last_win_h = h;
}
ANativeWindow_Buffer buffer;
if (ANativeWindow_lock(window_, &buffer, nullptr) == 0) {
uint8_t* dst_data[4] = { (uint8_t*)buffer.bits, nullptr, nullptr, nullptr };
int dst_line[4] = { buffer.stride * 4, 0, 0, 0 };
if (sws_ctx_) sws_scale(sws_ctx_, frame->data, frame->linesize, 0, frame->height, dst_data, dst_line);
ANativeWindow_unlockAndPost(window_);
}
}
}
av_packet_free(&pkt);
}
av_frame_free(&frame);
}
std::string PlayerEngine::getSubtitleTracks() { return subtitle_tracks_info_; }
void PlayerEngine::setSubtitleTrack(int streamIndex) { current_sub_stream_idx_ = streamIndex; }
+106
View File
@@ -0,0 +1,106 @@
#pragma once
#include "PacketQueue.h"
#include <string>
#include <thread>
#include <atomic>
#include <jni.h>
#include <android/native_window.h>
#include <aaudio/AAudio.h>
#include "sonic.h" // 💡 Sonic 헤더 추가
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswscale/swscale.h>
#include <libswresample/swresample.h>
#include <libavutil/opt.h>
#include <libavutil/channel_layout.h>
}
class PlayerEngine {
public:
PlayerEngine(JavaVM* vm, jobject listenerObj);
~PlayerEngine();
void setDataSource(int videoFd, int subtitleFd);
void prepareAsync(); // 💡 비동기 준비 시작 함수
void play(ANativeWindow* window);
void pause();
void stop();
void seekBy(double seconds);
void setSpeed(float speed);
double getCurrentPosition() const { return currentPosSec_; }
std::string getSubtitleTracks();
void setSubtitleTrack(int streamIndex);
double getDuration() const;
void seekTo(double seconds);
private:
void readThreadLoop(); // 패킷을 읽어서 큐에 넣는 스레드 (생산자)
void videoThreadLoop(); // 비디오 큐에서 꺼내서 렌더링하는 스레드 (소비자)
void audioThreadLoop(); // 오디오 큐에서 꺼내서 출력하는 스레드 (소비자)
std::atomic<bool> videoCodecFlushReq_{false};
std::atomic<bool> audioCodecFlushReq_{false};
PacketQueue videoQueue_;
PacketQueue audioQueue_;
std::thread readThread_;
std::thread videoThread_;
std::thread audioThread_;
std::atomic<bool> abortRequest_{false};
// 💡 [A/V Sync의 핵심] 현재 오디오가 어디까지 재생되었는지 기록하는 마스터 시계
std::atomic<double> audioClock_{0.0};
std::atomic<bool> isAbsoluteSeek_{false}; // 절대 위치 이동 여부 플래그
std::atomic<double> seekTargetSec_{0.0};
void prepareInternal(); // 💡 백그라운드 준비 스레드
void renderLoop();
void sendSubtitleToKotlin(const char* text);
int videoFd_ = -1;
int subtitleFd_ = -1;
std::atomic<bool> isPrepared_{false};
std::atomic<bool> isPlaying_{false};
std::atomic<bool> isPaused_{false};
std::thread prepareThread_;
std::thread renderThread_;
ANativeWindow* window_ = nullptr;
std::atomic<bool> seekReq_{false};
std::atomic<double> seekTargetOffset_{0.0};
std::atomic<float> playbackSpeed_{1.0f};
double currentPosSec_ = 0.0;
int current_sub_stream_idx_ = -1;
std::string subtitle_tracks_info_;
AVFormatContext* fmt_ctx_ = nullptr;
AVCodecContext* video_codec_ctx_ = nullptr;
AVCodecContext* sub_codec_ctx_ = nullptr;
AVCodecContext* audio_codec_ctx_ = nullptr;
SwsContext* sws_ctx_ = nullptr;
SwrContext* swr_ctx_ = nullptr;
int video_stream_idx_ = -1;
int sub_stream_idx_ = -1;
int audio_stream_idx_ = -1;
AAudioStream* audio_stream_ = nullptr;
JavaVM* jvm_ = nullptr;
jobject listenerObj_ = nullptr;
jmethodID subtitleMethodId_ = nullptr;
jmethodID videoSizeMethodId_ = nullptr;
jmethodID preparedMethodId_ = nullptr; // 💡 JNI 콜백 ID 추가
jmethodID errorMethodId_ = nullptr; // 💡 JNI 에러 콜백 ID 추가
sonicStream sonic_stream_ = nullptr;
};
+114 -2
View File
@@ -110,7 +110,8 @@ void Renderer::handleAnimationState(ANativeWindow_Buffer& buffer, int surfaceWid
bool isCycleComplete = animationStrategy_->execute(this, buffer, currentMedia_, surfaceWidth, surfaceHeight);
if (isCycleComplete && nextMedia_.isValid()) {
if ((isCycleComplete || forceNextFlag_) && nextMedia_.isValid()) {
forceNextFlag_ = false; // 플래그 초기화
lastAnimationState_ = animationStrategy_->getState();
predictedNextAnimationMode_ = predictNextAnimationMode();
currentState_ = RenderState::TRANSITIONING;
@@ -237,6 +238,26 @@ void Renderer::handleTransitionState(ANativeWindow_Buffer& buffer, int surfaceWi
}
}
void Renderer::updateWeatherBitmap(void* pixels, int width, int height) {
std::lock_guard<std::mutex> lock(weatherMutex_);
weatherWidth_ = width;
weatherHeight_ = height;
size_t size = static_cast<size_t>(width * height * 4);
weatherPixels_.assign(static_cast<uint8_t*>(pixels), static_cast<uint8_t*>(pixels) + size);
}
void Renderer::clearWeatherBitmap() {
std::lock_guard<std::mutex> lock(weatherMutex_);
weatherPixels_.clear();
}
void Renderer::setImageRenderFrame(int frame) {
std::lock_guard<std::mutex> lock(weatherMutex_);
imageFrame_ = frame;
}
// ====================================================================
// 메인 렌더링 루프 (교통정리 담당)
// ====================================================================
@@ -252,7 +273,7 @@ void Renderer::renderFrame(ANativeWindow* window) {
currentFrameDelay_ = std::chrono::milliseconds(static_cast<long long>(1000.0 / currentMedia_.getFps()));
LOGI("Media type: Video. Frame delay set to %lldms for %.2f FPS", currentFrameDelay_.count(), currentMedia_.getFps());
} else {
currentFrameDelay_ = std::chrono::milliseconds(70); // 이미지일 경우 30fps
currentFrameDelay_ = std::chrono::milliseconds(imageFrame_); // 이미지일 경우 30fps
LOGI("Media type: Image. Frame delay set to 33ms (~30 FPS)");
}
currentState_ = RenderState::ANIMATING;
@@ -288,6 +309,97 @@ void Renderer::renderFrame(ANativeWindow* window) {
break;
}
{
std::lock_guard<std::mutex> lock(weatherMutex_);
if (!weatherPixels_.empty()) {
// ======== ⬇️ 유기적인 움직임(Drift) 추가 ⬇️ ========
std::uniform_real_distribution<float> driftDist(-0.3f, 0.3f); // 방향 전환 폭을 살짝 키움
weatherVelX_ += driftDist(randomEngine_);
weatherVelY_ += driftDist(randomEngine_);
// 2. 현재 속도(벡터의 길이) 계산
float speed = std::sqrt(weatherVelX_ * weatherVelX_ + weatherVelY_ * weatherVelY_);
// 💡 여기서 최소/최대 이동 픽셀을 강제로 지정합니다.
float maxSpeed = 3.0f; // 프레임당 최대 5픽셀 이동 (답답하지 않게 상향)
float minSpeed = 1.0f; // 프레임당 최소 2픽셀 이동 보장 (절대 멈추거나 느려지지 않음)
// 속도 보정 (Normalization & Scaling)
if (speed < 0.001f) {
// 완전히 멈췄을 때의 방어 코드
weatherVelX_ = minSpeed;
weatherVelY_ = minSpeed;
} else if (speed > maxSpeed) {
// 한계 속도 초과 시 maxSpeed로 깎아냄
weatherVelX_ = (weatherVelX_ / speed) * maxSpeed;
weatherVelY_ = (weatherVelY_ / speed) * maxSpeed;
} else if (speed < minSpeed) {
// 🚀 속도가 너무 줄어들면 강제로 최소 속도(minSpeed) 픽셀만큼 끌어올림
weatherVelX_ = (weatherVelX_ / speed) * minSpeed;
weatherVelY_ = (weatherVelY_ / speed) * minSpeed;
}
// 3. 위치 업데이트 (최소 2px ~ 최대 5px 이동 보장)
weatherX_ += weatherVelX_;
weatherY_ += weatherVelY_;
// 4. 화면 경계 검사 및 튕기기 (Bounce)
// 튕길 때 속도가 너무 크게 변하지 않도록 난수 폭을 안정적으로(0.9~1.1) 조절
std::uniform_real_distribution<float> bounceDist(0.9f, 1.1f);
// 가로 경계
if (weatherX_ < 0) {
weatherX_ = 0;
weatherVelX_ = std::abs(weatherVelX_) * bounceDist(randomEngine_);
} else if (weatherX_ + weatherWidth_ > surfaceWidth) {
weatherX_ = (float)surfaceWidth - weatherWidth_;
weatherVelX_ = -std::abs(weatherVelX_) * bounceDist(randomEngine_);
}
// 세로 경계
if (weatherY_ < 0) {
weatherY_ = 0;
weatherVelY_ = std::abs(weatherVelY_) * bounceDist(randomEngine_);
} else if (weatherY_ + weatherHeight_ > surfaceHeight) {
weatherY_ = (float)surfaceHeight - weatherHeight_;
weatherVelY_ = -std::abs(weatherVelY_) * bounceDist(randomEngine_);
}
// 3. 그리기 (기존의 블렌딩 로직 사용)
uint32_t* dstPixels = (uint32_t*)buffer.bits;
int dstStride = buffer.stride;
for (int y = 0; y < weatherHeight_; ++y) {
int targetY = static_cast<int>(weatherY_) + y;
if (targetY < 0 || targetY >= surfaceHeight) continue;
for (int x = 0; x < weatherWidth_; ++x) {
int targetX = static_cast<int>(weatherX_) + x;
if (targetX < 0 || targetX >= surfaceWidth) continue;
const uint8_t* src = &weatherPixels_[(y * weatherWidth_ + x) * 4];
uint8_t alpha = src[3];
if (alpha == 0) continue;
uint32_t* dst = &dstPixels[targetY * dstStride + targetX];
// ======== ⬇️ 이 부분을 수정합니다 ⬇️ ========
// Red와 Blue 채널의 비트 시프트(Shift) 위치를 안드로이드 Bitmap(RGBA) 규격에 맞게 수정
uint8_t dr = (*dst) & 0xFF; // 배경의 Red
uint8_t dg = (*dst >> 8) & 0xFF; // 배경의 Green
uint8_t db = (*dst >> 16) & 0xFF; // 배경의 Blue
// 날씨 아이콘(src)의 R(0), G(1), B(2)와 배경(dst)을 알파 블렌딩
uint8_t r = (src[0] * alpha + dr * (255 - alpha)) / 255;
uint8_t g = (src[1] * alpha + dg * (255 - alpha)) / 255;
uint8_t b = (src[2] * alpha + db * (255 - alpha)) / 255;
*dst = (0xFF << 24) | (b << 16) | (g << 8) | r;
}
}
}
}
ANativeWindow_unlockAndPost(window);
}
+22 -1
View File
@@ -33,13 +33,18 @@ public:
void startRenderLoop(ANativeWindow* window);
void stopRenderLoop();
bool forceNextFlag_ = false;
// 공개 메서드 추가
void forceNext() { forceNextFlag_ = true; }
void setAnimationSpeed(float speed);
void setAnimationMode(int mode);
void setFadeDuration(int durationMs);
void setPageTurnDelay(int delayMs);
void setTransitionMode(int mode);
void setImageRenderFrame(int frame);
void updateWeatherBitmap(void* pixels, int width, int height);
void clearWeatherBitmap();
void drawMedia(ANativeWindow_Buffer& buffer, MediaAsset& media, float alpha, float finalOffsetX, float finalOffsetY, float finalScale);
void renderVideoFrame(MediaAsset& media, ANativeWindow_Buffer& buffer, float scale, float offsetX, float offsetY, float alpha);
void renderImageFrame(const MediaAsset& media, ANativeWindow_Buffer& buffer, float scale, float offsetX, float offsetY, float alpha);
@@ -84,4 +89,20 @@ private:
std::mt19937 randomEngine_;
void determineActiveAnimationMode();
std::vector<uint8_t> weatherPixels_;
int weatherWidth_ = 0;
int weatherHeight_ = 0;
int imageFrame_ = 33;
// 위치 변수
float weatherX_ = 100.0f;
float weatherY_ = 100.0f;
// 속도 변수 (방향 및 속도 조절)
float weatherVelX_ = 2.5f;
float weatherVelY_ = 2.0f;
std::mutex weatherMutex_;
};
+103
View File
@@ -0,0 +1,103 @@
#include <jni.h>
#include <android/native_window_jni.h>
#include "PlayerEngine.h"
extern JavaVM* g_vm;
template<typename T>
T* toPlayerNative(jlong handle) { return reinterpret_cast<T*>(handle); }
extern "C" {
JNIEXPORT jdouble JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeGetDuration(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
return engine ? engine->getDuration() : 0.0;
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeSeekTo(JNIEnv *env, jobject thiz, jlong handle, jdouble seconds) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->seekTo(seconds);
}
JNIEXPORT jlong JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeInit(JNIEnv *env, jobject thiz) {
PlayerEngine* engine = new PlayerEngine(g_vm, thiz);
return reinterpret_cast<jlong>(engine);
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeSeekBy(JNIEnv *env, jobject thiz, jlong handle, jdouble seconds) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->seekBy(seconds);
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeSetSpeed(JNIEnv *env, jobject thiz, jlong handle, jfloat speed) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->setSpeed(speed);
}
JNIEXPORT jdouble JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeGetCurrentPosition(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
return engine ? engine->getCurrentPosition() : 0.0;
}
JNIEXPORT jstring JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeGetSubtitleTracks(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
std::string tracks = engine ? engine->getSubtitleTracks() : "";
return env->NewStringUTF(tracks.c_str());
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeSetSubtitleTrack(JNIEnv *env, jobject thiz, jlong handle, jint index) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->setSubtitleTrack(index);
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeSetDataSource(JNIEnv *env, jobject thiz, jlong handle, jint videoFd, jint jSubFd) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->setDataSource(videoFd, jSubFd);
}
// 💡 추가된 비동기 준비
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativePrepareAsync(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->prepareAsync();
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativePause(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->pause();
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativePlay(JNIEnv *env, jobject thiz, jlong handle, jobject surface) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine && surface) {
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
ANativeWindow_setBuffersGeometry(window, 0, 0, WINDOW_FORMAT_RGBX_8888);
engine->play(window);
ANativeWindow_release(window);
}
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeStop(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) engine->stop();
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_player_NativePlayer_nativeDestroy(JNIEnv *env, jobject thiz, jlong handle) {
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
if (engine) delete engine;
}
}
+44 -1
View File
@@ -5,7 +5,7 @@
#include <thread>
#include "Renderer.h"
#include "Preloader.h"
#include <android/bitmap.h>
#define LOG_TAG "NativeRenderer"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
@@ -114,6 +114,37 @@ Java_bums_lunatic_launcher_wall_NativeRenderer_nativeRender(JNIEnv* env, jobject
ANativeWindow_release(window);
}
extern "C"
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_wall_NativeRenderer_nativeForceNext(JNIEnv* env, jobject, jlong nativeHandle) {
Renderer* renderer = reinterpret_cast<Renderer*>(nativeHandle);
if (renderer) {
renderer->forceNext(); // Renderer에 추가할 함수 호출
}
}
extern "C"
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_wall_NativeRenderer_nativeSetWeatherBitmap(JNIEnv* env, jobject, jlong nativeHandle, jobject bitmap) {
Renderer* renderer = reinterpret_cast<Renderer*>(nativeHandle);
if (!renderer) return;
// 비트맵이 null로 넘어오면 데이터를 지움
if (bitmap == nullptr) {
renderer->clearWeatherBitmap();
return;
}
AndroidBitmapInfo info;
void* pixels;
if (AndroidBitmap_getInfo(env, bitmap, &info) < 0) return;
if (AndroidBitmap_lockPixels(env, bitmap, &pixels) < 0) return;
renderer->updateWeatherBitmap(pixels, info.width, info.height);
AndroidBitmap_unlockPixels(env, bitmap);
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_wall_NativeRenderer_nativeStartNextPreload(JNIEnv* env, jobject, jlong nativeHandle, jint fd) {
Renderer* renderer = toNative<Renderer>(nativeHandle);
@@ -124,6 +155,18 @@ Java_bums_lunatic_launcher_wall_NativeRenderer_nativeStartNextPreload(JNIEnv* en
}
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_wall_NativeRenderer_nativeSetImageRenderFrame(JNIEnv* env, jobject, jlong nativeHandle, jint frame) {
Renderer* renderer = toNative<Renderer>(nativeHandle);
if (renderer) {
// Preloader가 Renderer의 일부가 되었다고 가정하고 호출 (추후 Renderer 수정 필요)
// 여기서는 간단하게 setNextMedia를 호출하는 것으로 변경합니다.
renderer->setImageRenderFrame(frame);
}
}
JNIEXPORT void JNICALL
Java_bums_lunatic_launcher_wall_NativeRenderer_nativeSetAnimationSpeed(JNIEnv* env, jobject, jlong nativeHandle, jfloat speed) {
Renderer* renderer = toNative<Renderer>(nativeHandle);
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
//
// Created by JIBUM HAN on 2026. 4. 11..
//
#ifndef SONIC_H_
#define SONIC_H_
/* Sonic library
Copyright 2010
Bill Cox
This file is part of the Sonic Library.
This file is licensed under the Apache 2.0 license.
*/
/*
The Sonic Library implements a new algorithm invented by Bill Cox for the
specific purpose of speeding up speech by high factors at high quality. It
generates smooth speech at speed up factors as high as 6X, possibly more. It is
also capable of slowing down speech, and generates high quality results
regardless of the speed up or slow down factor. For speeding up speech by 2X or
more, the following equation is used:
newSamples = period/(speed - 1.0)
scale = 1.0/newSamples;
where period is the current pitch period, determined using AMDF or any other
pitch estimator, and speed is the speedup factor. If the current position in
the input stream is pointed to by "samples", and the current output stream
position is pointed to by "out", then newSamples number of samples can be
generated with:
out[t] = (samples[t]*(newSamples - t) + samples[t + period]*t)/newSamples;
where t = 0 to newSamples - 1.
For speed factors < 2X, the PICOLA algorithm is used. The above
algorithm is first used to double the speed of one pitch period. Then, enough
input is directly copied from the input to the output to achieve the desired
speed up factor, where 1.0 < speed < 2.0. The amount of data copied is derived:
speed = (2*period + length)/(period + length)
speed*length + speed*period = 2*period + length
length(speed - 1) = 2*period - speed*period
length = period*(2 - speed)/(speed - 1)
For slowing down speech where 0.5 < speed < 1.0, a pitch period is inserted into
the output twice, and length of input is copied from the input to the output
until the output desired speed is reached. The length of data copied is:
length = period*(speed - 0.5)/(1 - speed)
For slow down factors below 0.5, no data is copied, and an algorithm
similar to high speed factors is used.
*/
/* Uncomment this to use sin-wav based overlap add which in theory can improve
sound quality slightly, at the expense of lots of floating point math. */
/* #define SONIC_USE_SIN */
#ifdef __cplusplus
extern "C" {
#endif
#ifdef SONIC_INTERNAL
/* The following #define's are used to change the names of the routines defined
* here so that a new library (i.e. speedy) can reuse these names, and then call
* the original names. We do this for two reasons: 1) we don't want to change
* the original API, and 2) we want to add a shim, using the original names and
* still call these routines.
*
* Original users of this API and the libsonic library need to do nothing. The
* original behavior remains.
*
* A new user that add some additional functionality above this library (a shim)
* should #define SONIC_INTERNAL before including this file, undefine all these
* symbols and call the sonicIntXXX functions directly.
*/
#define sonicCreateStream sonicIntCreateStream
#define sonicDestroyStream sonicIntDestroyStream
#define sonicWriteFloatToStream sonicIntWriteFloatToStream
#define sonicWriteShortToStream sonicIntWriteShortToStream
#define sonicWriteUnsignedCharToStream sonicIntWriteUnsignedCharToStream
#define sonicReadFloatFromStream sonicIntReadFloatFromStream
#define sonicReadShortFromStream sonicIntReadShortFromStream
#define sonicReadUnsignedCharFromStream sonicIntReadUnsignedCharFromStream
#define sonicFlushStream sonicIntFlushStream
#define sonicSamplesAvailable sonicIntSamplesAvailable
#define sonicGetSpeed sonicIntGetSpeed
#define sonicSetSpeed sonicIntSetSpeed
#define sonicGetPitch sonicIntGetPitch
#define sonicSetPitch sonicIntSetPitch
#define sonicGetRate sonicIntGetRate
#define sonicSetRate sonicIntSetRate
#define sonicGetVolume sonicIntGetVolume
#define sonicSetVolume sonicIntSetVolume
#define sonicGetQuality sonicIntGetQuality
#define sonicSetQuality sonicIntSetQuality
#define sonicGetSampleRate sonicIntGetSampleRate
#define sonicSetSampleRate sonicIntSetSampleRate
#define sonicGetNumChannels sonicIntGetNumChannels
#define sonicGetUserData sonicIntGetUserData
#define sonicSetUserData sonicIntSetUserData
#define sonicSetNumChannels sonicIntSetNumChannels
#define sonicChangeFloatSpeed sonicIntChangeFloatSpeed
#define sonicChangeShortSpeed sonicIntChangeShortSpeed
#define sonicEnableNonlinearSpeedup sonicIntEnableNonlinearSpeedup
#define sonicSetDurationFeedbackStrength sonicIntSetDurationFeedbackStrength
#define sonicComputeSpectrogram sonicIntComputeSpectrogram
#define sonicGetSpectrogram sonicIntGetSpectrogram
#endif /* SONIC_INTERNAL */
/* This specifies the range of voice pitches we try to match.
Note that if we go lower than 65, we could overflow in findPitchInRange */
#ifndef SONIC_MIN_PITCH
#define SONIC_MIN_PITCH 65
#endif /* SONIC_MIN_PITCH */
#ifndef SONIC_MAX_PITCH
#define SONIC_MAX_PITCH 400
#endif /* SONIC_MAX_PITCH */
/* The following values are used to clamp inputs such as speed to sane values.
*/
#define SONIC_MIN_VOLUME 0.01f
#define SONIC_MAX_VOLUME 100.0f
#define SONIC_MIN_SPEED 0.05f
#define SONIC_MAX_SPEED 20.0f
#define SONIC_MIN_PITCH_SETTING 0.05f
#define SONIC_MAX_PITCH_SETTING 20.0f
#define SONIC_MIN_RATE 0.05f
#define SONIC_MAX_RATE 20.0f
#define SONIC_MIN_SAMPLE_RATE 1000
#define SONIC_MAX_SAMPLE_RATE 500000
#define SONIC_MIN_CHANNELS 1
#define SONIC_MAX_CHANNELS 32
/* These are used to down-sample some inputs to improve speed */
#define SONIC_AMDF_FREQ 4000
struct sonicStreamStruct;
typedef struct sonicStreamStruct* sonicStream;
/* For all of the following functions, numChannels is multiplied by numSamples
to determine the actual number of values read or returned. */
/* Create a sonic stream. Return NULL only if we are out of memory and cannot
allocate the stream. Set numChannels to 1 for mono, and 2 for stereo. */
sonicStream sonicCreateStream(int sampleRate, int numChannels);
/* Destroy the sonic stream. */
void sonicDestroyStream(sonicStream stream);
/* Attach user data to the stream. */
void sonicSetUserData(sonicStream stream, void *userData);
/* Retrieve user data attached to the stream. */
void *sonicGetUserData(sonicStream stream);
/* Use this to write floating point data to be speed up or down into the stream.
Values must be between -1 and 1. Return 0 if memory realloc failed,
otherwise 1 */
int sonicWriteFloatToStream(sonicStream stream, const float* samples, int numSamples);
/* Use this to write 16-bit data to be speed up or down into the stream.
Return 0 if memory realloc failed, otherwise 1 */
int sonicWriteShortToStream(sonicStream stream, const short* samples, int numSamples);
/* Use this to write 8-bit unsigned data to be speed up or down into the stream.
Return 0 if memory realloc failed, otherwise 1 */
int sonicWriteUnsignedCharToStream(sonicStream stream, const unsigned char* samples,
int numSamples);
/* Use this to read floating point data out of the stream. Sometimes no data
will be available, and zero is returned, which is not an error condition. */
int sonicReadFloatFromStream(sonicStream stream, float* samples,
int maxSamples);
/* Use this to read 16-bit data out of the stream. Sometimes no data will
be available, and zero is returned, which is not an error condition. */
int sonicReadShortFromStream(sonicStream stream, short* samples,
int maxSamples);
/* Use this to read 8-bit unsigned data out of the stream. Sometimes no data
will be available, and zero is returned, which is not an error condition. */
int sonicReadUnsignedCharFromStream(sonicStream stream, unsigned char* samples,
int maxSamples);
/* Force the sonic stream to generate output using whatever data it currently
has. No extra delay will be added to the output, but flushing in the middle
of words could introduce distortion. */
int sonicFlushStream(sonicStream stream);
/* Return the number of samples in the output buffer */
int sonicSamplesAvailable(sonicStream stream);
/* Get the speed of the stream. */
float sonicGetSpeed(sonicStream stream);
/* Set the speed of the stream. */
void sonicSetSpeed(sonicStream stream, float speed);
/* Get the pitch of the stream. */
float sonicGetPitch(sonicStream stream);
/* Set the pitch of the stream. */
void sonicSetPitch(sonicStream stream, float pitch);
/* Get the rate of the stream. */
float sonicGetRate(sonicStream stream);
/* Set the rate of the stream. */
void sonicSetRate(sonicStream stream, float rate);
/* Get the scaling factor of the stream. */
float sonicGetVolume(sonicStream stream);
/* Set the scaling factor of the stream. */
void sonicSetVolume(sonicStream stream, float volume);
/* Chord pitch is DEPRECATED. AFAIK, it was never used by anyone. These
functions still exist to avoid breaking existing code. */
/* Get the chord pitch setting. */
int sonicGetChordPitch(sonicStream stream);
/* Set chord pitch mode on or off. Default is off. See the documentation
page for a description of this feature. */
void sonicSetChordPitch(sonicStream stream, int useChordPitch);
/* Get the quality setting. */
int sonicGetQuality(sonicStream stream);
/* Set the "quality". Default 0 is virtually as good as 1, but very much
* faster. */
void sonicSetQuality(sonicStream stream, int quality);
/* Get the sample rate of the stream. */
int sonicGetSampleRate(sonicStream stream);
/* Set the sample rate of the stream. This will drop any samples that have not
* been read. */
void sonicSetSampleRate(sonicStream stream, int sampleRate);
/* Get the number of channels. */
int sonicGetNumChannels(sonicStream stream);
/* Set the number of channels. This will drop any samples that have not been
* read. */
void sonicSetNumChannels(sonicStream stream, int numChannels);
/* This is a non-stream oriented interface to just change the speed of a sound
sample. It works in-place on the sample array, so there must be at least
speed*numSamples available space in the array. Returns the new number of
samples. */
int sonicChangeFloatSpeed(float* samples, int numSamples, float speed,
float pitch, float rate, float volume,
int useChordPitch, int sampleRate, int numChannels);
/* This is a non-stream oriented interface to just change the speed of a sound
sample. It works in-place on the sample array, so there must be at least
speed*numSamples available space in the array. Returns the new number of
samples. */
int sonicChangeShortSpeed(short* samples, int numSamples, float speed,
float pitch, float rate, float volume,
int useChordPitch, int sampleRate, int numChannels);
#ifdef SONIC_SPECTROGRAM
/*
This code generates high quality spectrograms from sound samples, using
Time-Aliased-FFTs as described at:
https://github.com/waywardgeek/spectrogram
Basically, two adjacent pitch periods are overlap-added to create a sound
sample that accurately represents the speech sound at that moment in time.
This set of samples is converted to a spetral line using an FFT, and the result
is saved as a single spectral line at that moment in time. The resulting
spectral lines vary in resolution (it is equal to the number of samples in the
pitch period), and the spacing of spectral lines also varies (proportional to
the numver of samples in the pitch period).
To generate a bitmap, linear interpolation is used to render the grayscale
value at any particular point in time and frequency.
*/
#define SONIC_MAX_SPECTRUM_FREQ 5000
struct sonicSpectrogramStruct;
struct sonicBitmapStruct;
typedef struct sonicSpectrogramStruct* sonicSpectrogram;
typedef struct sonicBitmapStruct* sonicBitmap;
/* sonicBitmap objects represent spectrograms as grayscale bitmaps where each
pixel is from 0 (black) to 255 (white). Bitmaps are rows*cols in size.
Rows are indexed top to bottom and columns are indexed left to right */
struct sonicBitmapStruct {
unsigned char* data;
int numRows;
int numCols;
};
/* Enable coomputation of a spectrogram on the fly. */
void sonicComputeSpectrogram(sonicStream stream);
/* Get the spectrogram. */
sonicSpectrogram sonicGetSpectrogram(sonicStream stream);
/* Create an empty spectrogram. Called automatically if sonicComputeSpectrogram
has been called. */
sonicSpectrogram sonicCreateSpectrogram(int sampleRate);
/* Destroy the spectrotram. This is called automatically when calling
sonicDestroyStream. */
void sonicDestroySpectrogram(sonicSpectrogram spectrogram);
/* Convert the spectrogram to a bitmap. Caller must destroy bitmap when done. */
sonicBitmap sonicConvertSpectrogramToBitmap(sonicSpectrogram spectrogram,
int numRows, int numCols);
/* Destroy a bitmap returned by sonicConvertSpectrogramToBitmap. */
void sonicDestroyBitmap(sonicBitmap bitmap);
int sonicWritePGM(sonicBitmap bitmap, char* fileName);
/* Add two pitch periods worth of samples to the spectrogram. There must be
2*period samples. Time should advance one pitch period for each call to
this function. */
void sonicAddPitchPeriodToSpectrogram(sonicSpectrogram spectrogram,
short* samples, int numSamples,
int numChannels);
#endif /* SONIC_SPECTROGRAM */
#ifdef __cplusplus
}
#endif
#endif /* SONIC_H_ */
@@ -230,7 +230,8 @@ object BookmarkUploader {
}
}
private fun saveImageToLocal(context: Context, imageData: ByteArray, imageUrl: String) {
private fun saveImageToLocal(context: Context, imageData: ByteArray, imageUrl: String) : String {
var fileName = android.net.Uri.parse(imageUrl).lastPathSegment ?: "img_${System.currentTimeMillis()}.jpg"
try {
val saveDir = File(context.getExternalFilesDir(null), "completed_torrents")
if (!saveDir.exists()) {
@@ -238,7 +239,7 @@ object BookmarkUploader {
}
// URL에서 원본 파일명 추출 시도 (실패 시 타임스탬프로 대체)
var fileName = android.net.Uri.parse(imageUrl).lastPathSegment ?: "img_${System.currentTimeMillis()}.jpg"
// 이름이 중복되어 덮어써지는 것을 막기 위해 타임스탬프를 앞에 붙여줍니다.
fileName = "${System.currentTimeMillis()}_$fileName"
@@ -250,6 +251,7 @@ object BookmarkUploader {
} catch (e: Exception) {
println("🔥 로컬 보관함 저장 실패: ${e.message}")
}
return fileName
}
/**
@@ -13,6 +13,10 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -46,6 +50,7 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import bums.lunatic.launcher.LunaticLauncher.Companion.isWifiConnected
import bums.lunatic.launcher.apps.AppDrawerBottomSheet
import bums.lunatic.launcher.common.CommonActivity
import bums.lunatic.launcher.databinding.LauncherActivityBinding
@@ -64,10 +69,13 @@ import bums.lunatic.launcher.receiver.NLService
import bums.lunatic.launcher.receiver.SmsReceiver
import bums.lunatic.launcher.settings.SettingsActivity
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.workers.BaseGetter
import bums.lunatic.launcher.workers.TorrentService
import bums.lunatic.launcher.workers.UsageLogType
import bums.lunatic.launcher.workers.UsageUpdateType
import bums.lunatic.launcher.workers.WorkersDb
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.Wearable
import com.google.android.material.color.DynamicColors
import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.ext.query
@@ -171,7 +179,11 @@ open class LauncherActivity : CommonActivity() {
// 각 구간별로 실행할 함수들 (예시)
private fun handleTopLongPress() {
// 상단 전용 기능
showToast("배경화면 전환 중...")
val intent = Intent("ACTION_NEXT_WALLPAPER")
// 같은 앱 내의 리시버로만 한정 (보안 및 정확성)
intent.setPackage(packageName)
sendBroadcast(intent)
}
private fun handleBottomLongPress() {
@@ -568,43 +580,91 @@ open class LauncherActivity : CommonActivity() {
}
})
requestSmsPermissionLauncher.launch(arrayOf(
android.Manifest.permission.RECEIVE_SMS,
android.Manifest.permission.READ_SMS
))
// requestSmsPermissionLauncher.launch(arrayOf(
// android.Manifest.permission.RECEIVE_SMS,
// android.Manifest.permission.READ_SMS
// ))
handleSharedIntent(intent)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
LunaticLauncher.isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
registerNetworkCallback()
}
private fun registerNetworkCallback() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
private var smsReceiver: SmsReceiver? = null
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
// 단순히 caps만 보지 말고, 현재 활성화된 기본 네트워크의 상태를 직접 다시 조회합니다.
val activeNet = connectivityManager.activeNetwork
val activeCaps = connectivityManager.getNetworkCapabilities(activeNet)
val wifiNow = activeCaps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
// 상태가 실제로 변했을 때만 업데이트하여 불필요한 로그와 로직 실행을 방지합니다.
if (isWifiConnected != wifiNow) {
isWifiConnected = wifiNow
}
val intent = Intent(this@LauncherActivity, TorrentService::class.java).apply {
putExtra("WIFI_STATE", isWifiConnected)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
startForegroundService(intent)
} catch (e: Exception){e.printStackTrace()}
} else {
startService(intent)
}
}
// 네트워크가 완전히 끊겼을 때도 처리해주는 것이 안전합니다.
override fun onLost(network: Network) {
val intent = Intent(this@LauncherActivity, TorrentService::class.java).apply {
putExtra("WIFI_STATE", false)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
connectivityManager.registerNetworkCallback(request, networkCallback!!)
}
// private var smsReceiver: SmsReceiver? = null
// 권한 요청 결과 처리기
private val requestSmsPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val granted = permissions[android.Manifest.permission.RECEIVE_SMS] ?: false
if (granted) {
Blog.LOGE("SMS 권한 승인됨 -> 리시버 동적 등록 실행")
registerSmsDynamicReceiver()
}
}
private fun registerSmsDynamicReceiver() {
if (smsReceiver == null) {
smsReceiver = SmsReceiver()
val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply {
priority = 2147483647 // 시스템 최우선 순위
}
// Android 14 이상(안드 16 포함) 필수 플래그: RECEIVER_EXPORTED
// 외부 앱(시스템 타워)으로부터 브로드캐스트를 받으려면 필수입니다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
registerReceiver(smsReceiver, filter)
}
}
}
// private val requestSmsPermissionLauncher = registerForActivityResult(
// ActivityResultContracts.RequestMultiplePermissions()
// ) { permissions ->
// val granted = permissions[android.Manifest.permission.RECEIVE_SMS] ?: false
// if (granted) {
// Blog.LOGE("SMS 권한 승인됨 -> 리시버 동적 등록 실행")
// registerSmsDynamicReceiver()
// }
// }
//
// private fun registerSmsDynamicReceiver() {
// if (smsReceiver == null) {
// smsReceiver = SmsReceiver()
// val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply {
// priority = 2147483647 // 시스템 최우선 순위
// }
//
// // Android 14 이상(안드 16 포함) 필수 플래그: RECEIVER_EXPORTED
// // 외부 앱(시스템 타워)으로부터 브로드캐스트를 받으려면 필수입니다.
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED)
// } else {
// registerReceiver(smsReceiver, filter)
// }
// }
// }
private fun updateWidgetOptions(appWidgetId: Int, hostView: AppWidgetHostView) {
val width = hostView.layoutParams.width
@@ -631,7 +691,7 @@ open class LauncherActivity : CommonActivity() {
override fun onPause() {
super.onPause()
Wearable.getMessageClient(this).removeListener(messageListener)
}
// 위젯 선택기 실행
fun selectWidget() {
@@ -778,10 +838,13 @@ open class LauncherActivity : CommonActivity() {
// }
try {
val widgets = WorkersDb.getRealm().query<WidgetData>().find()
widgets.forEach { widgetData ->
createWidget(null, widgetData)
}
}catch (e : Exception) {
val widgets = WorkersDb.getRealm().query<WidgetData>().find()
widgets.forEach { widgetData ->
createWidget(null, widgetData)
}
}
lateinit var scaleDetector: android.view.ScaleGestureDetector
@@ -905,12 +968,15 @@ open class LauncherActivity : CommonActivity() {
appWidgetHost?.stopListening() // [필수] 여기서 리스닝 중지 (onDestroy 대신 여기 추천)
}
private lateinit var connectivityManager: ConnectivityManager
private var networkCallback: ConnectivityManager.NetworkCallback? = null
override fun onDestroy() {
smsReceiver?.let {
unregisterReceiver(it)
smsReceiver = null
}
// smsReceiver?.let {
// unregisterReceiver(it)
// smsReceiver = null
// }
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
super.onDestroy()
}
@@ -942,6 +1008,33 @@ open class LauncherActivity : CommonActivity() {
field = value
}
private val messageListener = MessageClient.OnMessageReceivedListener { messageEvent ->
when (messageEvent.path) {
"/gesture/right",
"/gesture/next" -> {
// 시계에서 오른쪽으로 휘둘렀을 때: 예) 다음 배경화면으로 변경
}
"/gesture/left",
"/gesture/prev" -> {
onSwipeRight()
// 시계에서 왼쪽으로 휘둘렀을 때: 예) 앱 서랍 열기
// showToast("시계 제스처: 앱 서랍")
}
"/gesture/down" ->{
onSwipeDown()
}
"/gesture/up"->{
val intent = Intent("ACTION_NEXT_WALLPAPER").setPackage(packageName)
sendBroadcast(intent)
showToast("시계 제스처: 다음 배경화면")
// showAppDrawer()
}
}
}
@RequiresApi(Build.VERSION_CODES.O_MR1)
override fun onResume() {
super.onResume()
@@ -952,6 +1045,7 @@ open class LauncherActivity : CommonActivity() {
requestPermission()
}
resumeCount += 1
Wearable.getMessageClient(this).addListener(messageListener)
}
private fun openSearch() {
@@ -51,6 +51,7 @@ internal class LunaticLauncher : Application() {
companion object {
var appContext : LunaticLauncher? = null
var mHourlyLogWriter : HourlyLogWriter? = null
var isWifiConnected : Boolean = false
private var sRuntime: GeckoRuntime? = null
fun getRuntime() : GeckoRuntime? {
appContext?.initGeckoRuntime()
@@ -22,6 +22,8 @@ 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
import bums.lunatic.launcher.utils.Blog
@@ -93,6 +95,10 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
fun showReader() {
startActivity(Intent(context, WebReaderActivity::class.java))
}
private var currentScope: String = CategoryGrouper.SCOPE_ALL
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -245,6 +251,10 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
filterAppsList(keyword)
}
binding.searchReader.setOnClickListener {
showReader()
dismiss()
}
binding.searchTaxi.setOnClickListener {
@@ -9,6 +9,7 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import bums.lunatic.launcher.apps.SearchMenu
import bums.lunatic.launcher.helpers.PrefBoolean
import bums.lunatic.launcher.utils.Blog
@@ -60,10 +60,101 @@ import android.os.Vibrator
import android.view.KeyEvent
import androidx.annotation.RequiresPermission
import androidx.core.content.ContentProviderCompat.requireContext
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.workDataOf
import bums.lunatic.launcher.home.GeckoWeb.Companion.currentCookieString
import bums.lunatic.launcher.home.GeckoWeb.Companion.currentCookieUrlString
import bums.lunatic.launcher.model.QuoteItem
import bums.lunatic.launcher.model.WallContentGroup
import bums.lunatic.launcher.workers.WorkersDb
import com.google.common.reflect.TypeToken
import com.google.gson.Gson
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Calendar
import kotlin.math.abs
data class ZenQuoteResponse(val q: String, val a: String)
data class KorAdviceResponse(val author: String, val message: String)
class QuoteFetchWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
fetchAndSaveQuotes()
Result.success()
} catch (e: Exception) {
Blog.LOGE("QuoteFetchWorker Failed", e)
Result.retry() // 실패 시 재시도
}
}
private suspend fun fetchAndSaveQuotes() {
withContext(Dispatchers.IO) {
Blog.LOGE("fetchAndSaveQuotes")
val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
val gson = Gson()
val realm = WorkersDb.getRealm()
// 5회 반복 호출
try {
val zenRequest = Request.Builder().url("https://zenquotes.io/api/random").build()
val zenResponse = client.newCall(zenRequest).execute()
if (zenResponse.isSuccessful) {
zenResponse.body?.string()?.let { json ->
val listType = object : TypeToken<List<ZenQuoteResponse>>() {}.type
val zenList: List<ZenQuoteResponse> = gson.fromJson(json, listType)
if (zenList.isNotEmpty()) {
val quote = zenList[0]
realm.writeBlocking {
copyToRealm(QuoteItem().apply {
textEn = quote.q
authorEn = quote.a
sourceApi = "ZENQUOTES"
isTranslated = false
timestamp = System.currentTimeMillis()
})
}
}
}
}
val korRequest = Request.Builder().url("https://korean-advice-open-api.vercel.app/api/advice").build()
val korResponse = client.newCall(korRequest).execute()
if (korResponse.isSuccessful) {
korResponse.body?.string()?.let { json ->
val korQuote = gson.fromJson(json, KorAdviceResponse::class.java)
realm.writeBlocking {
copyToRealm(QuoteItem().apply {
textKo = korQuote.message
authorKo = korQuote.author
sourceApi = "KOR_ADVICE"
isTranslated = false
timestamp = System.currentTimeMillis()
})
}
}
}
} catch (e: Exception) {
Blog.LOGE("Quote Failed", e)
}
}
}
}
class WallpaperAutoChangeWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
@@ -159,6 +250,155 @@ class AggregatedNewsWorker(private val context: Context, params: WorkerParameter
}
}
//class WallContentsWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
//
// // 1. 필요한 Gemini 응답 데이터 클래스 추가
// data class GeminiResponse(val candidates: List<Candidate>)
// data class Candidate(val content: Content)
// data class Content(val parts: List<Part>)
// data class Part(val text: String)
//
// // 불필요한 최상단 따옴표를 제거한 깔끔한 프롬프트
//
//
// override suspend fun doWork(): Result {
// return try {
// refreshGeminiContent()
// Result.success()
// } catch (e: Exception) {
// Blog.LOGE("WallContentsWorker Failed", e)
// Result.retry() // 일시적 오류일 수 있으므로 재시도 반환
// }
// }
//
// private fun getTimeBasedContext(): String {
// val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
//
// return when (hour) {
// in 6..10 -> "지금은 아침 시간이야. 카페에서 커피/브런치를 주문하거나, 상쾌하게 아침 인사를 나누는 상황에 맞는 가벼운 스몰토크를 알려줘."
// in 11..16 -> "지금은 낮 시간이야. 관광지에서 길을 묻거나, 점심 식사 주문, 상점에서 물건을 구매할 때 쓰는 활동적인 실생활 표현을 알려줘."
// in 17..21 -> "지금은 저녁 시간이야. 분위기 있는 식당에서 저녁을 먹거나, 펍에서 맥주 한잔하며 현지인과 가볍게 나누는 스몰토크를 알려줘."
// else -> "지금은 늦은 밤이야. 숙소(호텔) 프론트에 문의하거나, 편의점에서 야식을 사고, 내일 일정을 묻는 등 밤 시간에 필요한 실용적인 표현을 알려줘."
// }
// }
//
// private suspend fun refreshGeminiContent() {
// val apiKey = PrefString.geminiApiKey.get("") // 설정에서 가져온 키
// if (apiKey.isEmpty()) {
// Blog.LOGE("Gemini API Key is empty.")
// return
// }
//
// val resultJson = callGeminiApi(apiKey, getDynamicPrompt()) ?: return
// Blog.LOGE("resultJson >>> $resultJson")
// val newGroups = parseGeminiResponse(resultJson) ?: return
// Blog.LOGE("newGroups.size ${newGroups.size}")
// WorkersDb.insertExpressions(newGroups)
//
// }
//
// private fun getDynamicPrompt(): String {
// val myStyle = PrefString.travelStyle.get("주로 현지 로컬 맛집을 찾아다니고, 가벼운 스몰토크를 즐기는 편이야. 흡연 및 음주를 즐기는 편이고, 여행은 항상 부인과 같이 다녀")
// val timeContext = getTimeBasedContext()
//
// return """
// 너는 여행 및 외국어 학습 전문가야. 아래의 [나의 스타일]과 [현재 상황]을 반영해서, 당장 써먹을 수 있는 유용한 실용 회화 표현 12가지를 선정해줘.
//
// [나의 스타일]: $myStyle
// [현재 상황]: $timeContext
//
// 각 표현에 대해 영어(EN), 스페인어(ES), 일본어(JA), 중국어(ZH) 네 가지 버전을 제공해.
//
// 중요 규칙:
// - 모든 외국어 문장의 'pron'에는 최대한 정확한 한국어 발음을 적어줘.
// - 일본어(JA)는 한자와 가나 혼용, 중국어(ZH)는 간체자를 쓰고 'pron'에 병음과 한글 발음을 적어줘.
// - 각 번역본마다 그 문장에서 가장 중요한 '핵심 단어나 숙어' 2~3개를 뽑아서 `words` 배열에 원단어(word)와 뜻(meaning)으로 정리해줘.
//
// 반드시 아래 JSON 배열 형식으로만 응답해:
// [
// {
// "topic": "주제",
// "translations": [
// {
// "lang": "EN",
// "main": "Sentence",
// "sub": "한국어 뜻",
// "pron": "한글 발음",
// "words": [
// {"word": "단어1", "meaning": "뜻1", "pron": "단어1 발음"},
// {"word": "단어2", "meaning": "뜻2", "pron": "단어2 발음"}
// ]
// },
// ... (ES, JA, ZH도 동일한 구조)
// ]
// }
// ]
// // (이 형식으로 총 12개 세트를 제공할 것)
// """.trimIndent()
// }
//
// private suspend fun callGeminiApi(apiKey: String, prompt: String): String? {
// return withContext(Dispatchers.IO) {
// val client = OkHttpClient.Builder()
// .connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간
// .readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!)
// .writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간
// .build()
// val url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey"
//
// // 3. Gson을 사용하여 JSON 구조를 안전하게 생성 (따옴표 깨짐 방지)
// val requestMap = mapOf(
// "contents" to listOf(
// mapOf("parts" to listOf(mapOf("text" to prompt)))
// ),
// "generationConfig" to mapOf(
// "responseMimeType" to "application/json"
// )
// )
//
// val jsonRequest = Gson().toJson(requestMap)
//
// val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
// val request = Request.Builder()
// .url(url)
// .post(body)
// .build()
//
// try {
// val response = client.newCall(request).execute()
// if (response.isSuccessful) {
// response.body?.string()
// } else {
// Blog.LOGE("Gemini API Error: ${response.code} ${response.message}")
// null
// }
// } catch (e: Exception) {
// e.printStackTrace()
// null
// }
// }
// }
//
// private fun parseGeminiResponse(jsonString: String): List<WallContentGroup>? {
// return try {
// val gson = Gson()
// val fullResponse = gson.fromJson(jsonString, GeminiResponse::class.java)
// val innerJson = fullResponse.candidates[0].content.parts[0].text
//
// val groupType = object : TypeToken<List<WallContentGroup>>() {}.type
// gson.fromJson(innerJson, groupType)
// } catch (e: Exception) {
// Blog.LOGE("Parsing Error: ${e.message}")
// null
// }
// }
//
//}
class VibrationWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as android.os.Vibrator
@@ -276,6 +516,7 @@ class ForeGroundService : Service() {
}
fun addToTargetYtubeUrl(url : String? = null, forMusic: Boolean = false) {
Blog.LOGE("url $url")
url?.let { url ->
if (url.length > 0) {
targetUrls.add(url to forMusic)
@@ -298,41 +539,55 @@ class ForeGroundService : Service() {
}
}
var lastProGress = -1f
fun downloadVideo(url: String?, forMusic: Boolean = false) {
url?.let {
CoroutineScope(Dispatchers.IO).launch {
try {
val youtubeDLDir = File(getExternalFilesDir(null), "completed_torrents")
val baseDir = File(getExternalFilesDir(null), "completed_torrents")
// 2. youtube 전용 폴더 생성
val youtubeDLDir = File(baseDir, "Youtube")
if (!youtubeDLDir.exists()) youtubeDLDir.mkdirs()
val command = YoutubeDLRequest(url).apply {
addOption("-q") // 로그 최소화
addOption("--newline") // 줄바꿈 단위로 출력 (콜백 트리거 핵심)
addOption("--progress") // 진행 상태 강제 출력 // 로그 최소화
addOption("--no-warnings") // 경고 숨김
val cookieFile = File(this@ForeGroundService.cacheDir, "cookies.txt")
addOption("--cookies", "${cookieFile.absolutePath}")
// 출력 경로
addOption("-o", "${youtubeDLDir.absolutePath}/%(title)s.%(ext)s")
addOption("-o", "${youtubeDLDir.absolutePath}/[%(uploader)s] %(title)s [%(id)s].%(ext)s")
if (forMusic) {
// 음악 전용 옵션
addOption("-x") // 오디오 추출
addOption("--audio-format", "mp3")
addOption("--extractor-args", "youtube:player_client=web_music,android")
addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
} else {
// if (forMusic) {
// // 음악 전용 옵션
// addOption("-x") // 오디오 추출
// addOption("--audio-format", "mp3")
// addOption("--extractor-args", "youtube:player_client=web_music,android")
// addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
// } else {
// 일반 영상용
addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
}
// addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
addOption("-f", "best[ext=mp4]")
// 안정성 향상 옵션 (모두에 적용)
// }
addOption("--no-check-certificates")
addOption("--verbose")
addOption("--no-mtime") // 파일 수정시간 안 건드림
addOption("--no-continue") // 이어받기 하지 않음
addOption("--force-overwrites") // 강제 덮어쓰기
addOption("--restrict-filenames") // 특수문자 제한
}
currentProcessId = UUID.randomUUID().toString()
YoutubeDL.getInstance()
var response = YoutubeDL.getInstance()
.execute(command, currentProcessId) { progress, est, str ->
startForeGround(100, progress.toInt(), str, false)
// Blog.LOGE("progress $progress est $est str $str" )
if(progress == 100.0f || progress == 0.0f || abs(lastProGress - progress) > 3) {
startForeGround(100, progress.toInt(), str, false)
lastProGress = progress
}
if (progress >= 100) {
currentProcessId = null
if ((targetUrls?.size ?: 0) > 0) {
@@ -340,6 +595,10 @@ class ForeGroundService : Service() {
}
}
}
Blog.LOGE("url $url $currentProcessId")
Blog.LOGE("Exit Code: ${response.exitCode}")
Blog.LOGE("Out: ${response.out}")
Blog.LOGE("Error: ${response.err}")
} catch (e: Exception) {
Blog.LOGE("Download Error", e)
currentProcessId = null
@@ -467,12 +726,30 @@ class ForeGroundService : Service() {
.build()
// 2. 뉴스 피드: 사용자가 설정한 간격 (예: 1시간)
val newsRequest = PeriodicWorkRequestBuilder<AggregatedNewsWorker>(PrefLong.shortTimePeriod.get(20L), TimeUnit.MINUTES)
val newsRequest = PeriodicWorkRequestBuilder<AggregatedNewsWorker>(PrefLong.longTimePeriod.get(120L), TimeUnit.MINUTES)
.build()
// 기존의 수많은 enqueue 코드를 이 두 개로 대체
workManager.enqueueUniquePeriodicWork("AggregatedSystemWork", ExistingPeriodicWorkPolicy.KEEP, systemRequest)
workManager.enqueueUniquePeriodicWork("AggregatedNewsWork", ExistingPeriodicWorkPolicy.KEEP, newsRequest)
val quoteRequest = PeriodicWorkRequestBuilder<QuoteFetchWorker>(
2, TimeUnit.HOURS // 1시간 간격 설정
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // 네트워크가 연결된 상태에서만 실행
.build()
)
.build()
workManager.enqueueUniquePeriodicWork(
"QuoteFetchWork",
ExistingPeriodicWorkPolicy.KEEP,
quoteRequest
)
}
@@ -594,9 +871,9 @@ class ForeGroundService : Service() {
val majorClass = device?.bluetoothClass?.majorDeviceClass
if (majorClass == BluetoothClass.Device.Major.AUDIO_VIDEO) {
Blog.LOGE("Audio Bluetooth Connected: ${device?.name ?: "Unknown"}")
if ("BUMz pod".equals(device?.name)) {
launchAppleMusic(context)
}
// if ("BUMz pod".equals(device?.name)) {
// launchAppleMusic(context)
// }
}
}
@@ -648,4 +925,10 @@ class ForeGroundService : Service() {
}
}
}
}
data class GeminiResponse(val candidates: List<Candidate>)
data class Candidate(val content: Content)
data class Content(val parts: List<Part>)
data class Part(val text: String)
@@ -10,10 +10,12 @@ enum class PrefString : PrefKey<String> {
defaultTimeFormat,
defaultDateFormat,
weatherApiKey,
geminiApiKey,
locationApi,
telegramBotApi,
telegramMyId,
telegramSendTarget,
travelStyle,
carName;
override fun set(value: String) {PrefHelper.putString(this.name, value)}
override fun get(def : String?) : String = PrefHelper.getString(this.name, def as? String ?: "") ?: ""
@@ -6,9 +6,14 @@ import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
@@ -21,13 +26,16 @@ import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import bums.lunatic.launcher.R
import bums.lunatic.launcher.player.DocumentViewerActivity
import bums.lunatic.launcher.player.ImageViewerActivity
import bums.lunatic.launcher.player.NativePlayer
import bums.lunatic.launcher.player.PlayerActivity
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -36,7 +44,7 @@ import kotlinx.coroutines.withContext
import java.io.File
enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
enum class FileSortType(val label : String) { NAME("파일명") , DOWNLOAD_DATE("다운로드"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
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 }
// 💡 숫자 크기를 인식하는 정렬 비교자
@@ -77,6 +85,10 @@ enum class RenameMode(val label: String) {
SEQUENTIAL("순차적 직접 변경")
}
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp","srt", "smi", "ass", "vtt")
class CompletedFilesFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
@@ -87,7 +99,7 @@ class CompletedFilesFragment : Fragment() {
private var allFiles = listOf<File>()
private var currentFilter = FileFilterType.ALL
private var currentSort = FileSortType.NAME
private var currentSort = FileSortType.LAST_USED
private var currentViewMode = FileViewMode.LIST_TEXT
private var isDescending = true
private var searchQuery = "" // 💡 실시간 검색어 저장용 변수
@@ -95,9 +107,7 @@ class CompletedFilesFragment : Fragment() {
private var isSelectionMode = false
private val selectedFiles = mutableSetOf<File>()
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
private lateinit var backPressedCallback: OnBackPressedCallback
fun backPress() = backPressedCallback.handleOnBackPressed()
@@ -142,6 +152,10 @@ class CompletedFilesFragment : Fragment() {
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
}
private fun showVideoPreviewDialog(file: File) {
}
private fun setupRecyclerView(view: View) {
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
updateRecyclerViewLayoutManager()
@@ -166,7 +180,37 @@ class CompletedFilesFragment : Fragment() {
currentDir = file
loadFiles()
} else {
openPrivateFile(requireContext(), file)
val intentFlags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
if (extVideos.contains(file.extension.lowercase())) {
trackFileAccess(file.name)
loadFiles()
val intent = Intent(requireContext(), PlayerActivity::class.java).apply {
putExtra("VIDEO_PATH", file.absolutePath)
// 💡 기존 인스턴스가 있으면 제거하고 새로 시작
flags = intentFlags
}
startActivity(intent)
} else if (extImages.contains(file.extension.lowercase())) {
trackFileAccess(file.name)
loadFiles()
val intent = Intent(requireContext(), ImageViewerActivity::class.java).apply {
putExtra("IMAGE_PATH", file.absolutePath)
// 💡 이미지 뷰어도 동일하게 적용
flags = intentFlags
}
startActivity(intent)
} else if (extDocs.contains(file.extension.lowercase())) {
trackFileAccess(file.name)
loadFiles()
val intent = Intent(requireContext(), DocumentViewerActivity::class.java).apply {
putExtra("FILE_PATH", file.absolutePath)
// 💡 문서 뷰어도 동일하게 적용
flags = intentFlags
}
startActivity(intent)
} else {
openPrivateFile(requireContext(), file)
}
}
}
},
@@ -174,11 +218,11 @@ class CompletedFilesFragment : Fragment() {
if (file.name == "..") return@CompletedFilesAdapter
// 💡 폴더를 길게 누르면 -> 폴더 상세 삭제 다이얼로그 호출
if (file.isDirectory && !isSelectionMode) {
if (file.isDirectory && !isSelectionMode && !isProtectedFile(file)) {
showFolderDeleteDialog(file)
}
// 💡 파일을 길게 누르면 -> 멀티 선택 모드 시작
else if (!isSelectionMode) {
else if (!isSelectionMode ) {
toggleSelectionMode(true)
selectedFiles.add(file)
adapter.updateSelection(selectedFiles)
@@ -232,7 +276,11 @@ class CompletedFilesFragment : Fragment() {
loadFiles()
}
.setNeutralButton("폴더 전체 삭제") { _, _ ->
// 💡 여기서도 다이얼로그 안에서 다이얼로그를 부릅니다! (안전한 패턴)
if (isProtectedFile(folder)) {
Toast.makeText(context, "Images/Videos 폴더는 전체 삭제할 수 없습니다.", Toast.LENGTH_SHORT).show()
return@setNeutralButton
}
android.app.AlertDialog.Builder(requireContext())
.setMessage("정말로 폴더와 내부의 모든 파일을 삭제하시겠습니까?")
.setPositiveButton("") { _, _ ->
@@ -370,6 +418,45 @@ class CompletedFilesFragment : Fragment() {
.show()
}
private fun copyToDownloadFolder(filesToCopy: List<File>) {
if (filesToCopy.isEmpty()) return
// 1. 기기의 공용 Download 폴더 경로 가져오기
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
// 다운로드 폴더 안에 'Lunatic'이라는 앱 전용 폴더를 만들어 깔끔하게 모아둡니다.
val targetDir = File(downloadDir, "Lunatic_Downloads")
if (!targetDir.exists()) targetDir.mkdirs()
Toast.makeText(context, "다운로드 폴더로 복사를 시작합니다...", Toast.LENGTH_SHORT).show()
// 2. 용량이 클 수 있으므로 백그라운드 스레드에서 복사 진행
CoroutineScope(Dispatchers.IO).launch {
var copyCount = 0
filesToCopy.forEach { file ->
try {
val destFile = File(targetDir, file.name)
if (file.isDirectory) {
// 폴더일 경우 내부 파일까지 통째로 복사
file.copyRecursively(destFile, overwrite = true)
} else {
// 단일 파일 복사
file.copyTo(destFile, overwrite = true)
}
copyCount++
} catch (e: Exception) {
e.printStackTrace()
}
}
// 3. 복사 완료 후 UI 스레드에서 결과 알림
withContext(Dispatchers.Main) {
Toast.makeText(requireContext(), "${copyCount}개 항목 복사 완료\n(Download/Lunatic_Downloads)", Toast.LENGTH_LONG).show()
toggleSelectionMode(false) // 선택 모드 해제
}
}
}
// 💡 선택한 파일 이동 다이얼로그
private fun showMoveDialog() {
if (selectedFiles.isEmpty()) return
@@ -412,6 +499,30 @@ class CompletedFilesFragment : Fragment() {
FileViewMode.GRID_SMALL -> "apps"
}
}
private fun saveFolderSortSetting(folderPath: String, sortType: FileSortType, isDesc: Boolean) {
val prefs = requireContext().getSharedPreferences("FolderSortPrefs", Context.MODE_PRIVATE)
prefs.edit()
.putString("${folderPath}_sort", sortType.name)
.putBoolean("${folderPath}_desc", isDesc)
.apply()
}
// 💡 폴더별로 저장된 정렬 방식 불러오기 (기본값은 최신순 / 내림차순)
private fun loadFolderSortSetting(folderPath: String) {
val prefs = requireContext().getSharedPreferences("FolderSortPrefs", Context.MODE_PRIVATE)
val savedSortName = prefs.getString("${folderPath}_sort", FileSortType.LAST_USED.name)
currentSort = try {
FileSortType.valueOf(savedSortName ?: FileSortType.LAST_USED.name)
} catch (e: Exception) {
FileSortType.LAST_USED
}
isDescending = prefs.getBoolean("${folderPath}_desc", true)
}
val MAX_COUNT = 9
private fun setupControls(view: View) {
val layoutTitleDefault = view.findViewById<View>(R.id.layoutTitleDefault)
val layoutTitleSearch = view.findViewById<View>(R.id.layoutTitleSearch)
@@ -459,22 +570,58 @@ class CompletedFilesFragment : Fragment() {
// 💡 선택 액션 바 버튼 이벤트
view.findViewById<View>(R.id.btnSelectAll)?.setOnClickListener {
selectedFiles.clear()
selectedFiles.addAll(adapter.getAll())
selectedFiles.addAll(adapter.getAll().filter { it.name.contains("..") == false })
adapter.updateSelection(selectedFiles)
}
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
view.findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
view.findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
if (selectedFiles.isEmpty()) return@setOnClickListener
android.app.AlertDialog.Builder(requireContext())
.setTitle("다운로드 폴더로 내보내기")
.setMessage("선택한 ${selectedFiles.size}개 항목을 기기의 다운로드 폴더(Lunatic_Downloads)로 복사하시겠습니까?")
.setPositiveButton("복사") { _, _ ->
copyToDownloadFolder(selectedFiles.toList())
}
.setNegativeButton("취소", null)
.show()
}
view.findViewById<View>(R.id.btnDeleteSelected)?.setOnClickListener {
if (selectedFiles.isEmpty()) return@setOnClickListener
// 💡 보호 대상 필터링
val protectedCount = selectedFiles.count { isProtectedFile(it) }
// val filesToDelete : MutableList<File> = mutableListOf<File>().apply {
// addAll(selectedFiles.filter { !isProtectedFile(it) })
// }
if (protectedCount == 1) {
}
val message = if (protectedCount > MAX_COUNT) {
Toast.makeText(context, "보호된 폴더 내부는 ${MAX_COUNT}개 이하만 동시 삭제 가능함.", Toast.LENGTH_SHORT).show()
return@setOnClickListener
} else {
"선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?"
}
android.app.AlertDialog.Builder(requireContext())
.setMessage("선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?")
.setMessage(message)
.setPositiveButton("삭제") { _, _ ->
var delCount = 0
selectedFiles.forEach { file ->
if (file.isDirectory) { file.deleteRecursively(); delCount++ }
else if (file.delete()) { delCount++ }
if (isProtectedFolder(file)){
} else {
if (file.isDirectory) {
if (file.deleteRecursively()) delCount++
} else if (file.delete()) {
delCount++
}
}
}
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
toggleSelectionMode(false)
@@ -536,6 +683,7 @@ class CompletedFilesFragment : Fragment() {
spinnerSort.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
currentSort = FileSortType.values()[position]
saveFolderSortSetting(currentDir.absolutePath, currentSort, isDescending)
applyFilterAndSort()
}
override fun onNothingSelected(p0: AdapterView<*>?) {}
@@ -545,6 +693,7 @@ class CompletedFilesFragment : Fragment() {
tvSortOrder.setOnClickListener {
isDescending = !isDescending
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
saveFolderSortSetting(currentDir.absolutePath, currentSort, isDescending)
applyFilterAndSort()
}
}
@@ -731,61 +880,92 @@ class CompletedFilesFragment : Fragment() {
.show()
}
private fun getTargetFolderName(file: File): String {
val ext = file.extension.lowercase()
return when {
// 이미지 확장자 세트에 포함된 경우
extImages.contains(ext) -> "Images"
// 비디오 확장자 세트에 포함된 경우
extVideos.contains(ext) -> "Videos"
// 문서 확장자 세트에 포함된 경우
extDocs.contains(ext) -> "Documents"
// 그 외 모든 파일은 Etc 폴더로 분류
else -> "Etc"
}
}
private fun organizeRootFiles() {
CoroutineScope(Dispatchers.IO).launch {
// 1. 루트 폴더의 파일 목록 가져오
val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
// 1. 기초 설정
val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
val trashDir = File(rootDir, "trash").apply { if (!exists()) mkdirs() }
val videoTargetDir = File(rootDir, "Videos").apply { if (!exists()) mkdirs() }
var movedCount = 0
// 2. 널브러진 파일들을 확장자별 폴더로 이동
// [단계 1] 루트에 널브러진 파일 기본 정리
filesInRoot?.forEach { file ->
val ext = file.extension.lowercase()
val folderName = when {
extImages.contains(ext) -> "Images"
extVideos.contains(ext) -> "Videos"
extDocs.contains(ext) -> "Documents"
else -> "Etc"
}
val targetDir = File(rootDir, folderName)
if (!targetDir.exists()) targetDir.mkdirs()
if (file.renameTo(File(targetDir, file.name))) {
movedCount++
}
val folderName = getTargetFolderName(file)
val targetDir = File(rootDir, folderName).apply { if (!exists()) mkdirs() }
if (file.renameTo(File(targetDir, file.name))) movedCount++
}
// 💡 3. 빈 폴더 싹쓸이 기능 추가 (Bottom-Up 방식)
var deletedFolderCount = 0
// [단계 2] 특수 폴더 정리 및 잔여 파일 trash 이동
val subtitleExts = setOf("srt", "smi", "ass", "vtt")
val protectedDirs = setOf("Images", "Videos", "Documents", "Etc", "trash", "Youtube")
// walkBottomUp()을 사용하면 가장 깊은 하위 폴더부터 위로 올라오면서 검사합니다.
rootDir.walkBottomUp().forEach { dir ->
// 최상위 루트 폴더 자체는 지우면 안 되므로 통과
if (dir != rootDir && dir.isDirectory) {
// 폴더 내부에 아무것도 없다면 삭제
if (dir.listFiles()?.isEmpty() == true) {
if (dir.delete()) {
deletedFolderCount++
rootDir.listFiles()?.filter { it.isDirectory && !protectedDirs.contains(it.name) }?.forEach { folder ->
val innerFiles = folder.listFiles() ?: return@forEach
// 특수 조건(1GB 영상) 확인용 데이터
val videoFiles = innerFiles.filter { extVideos.contains(it.extension.lowercase()) }.filter { it.length() >= 1024 * 1024 * 1024 }
val potentialSubtitles = innerFiles.filter { subtitleExts.contains(it.extension.lowercase()) }
if (videoFiles.isNotEmpty()) {
// 조건 만족 시 영상+자막 이동
videoFiles.forEach { videoFile ->
if (videoFile.renameTo(File(videoTargetDir, videoFile.name))) {
movedCount++
potentialSubtitles.forEach { subFile ->
if (subFile.renameTo(File(videoTargetDir, subFile.name))) movedCount++
}
}
}
}
// [단계 3] 💡 위 로직에서 살아남은 나머지 잔여 파일들을 trash로 이동
// 이동 후 남은 파일을 다시 체크
folder.listFiles()?.filter { it.isFile }?.forEach { remainingFile ->
// 리네임 규칙: '원래폴더명_파일명'
val newName = "${folder.name}_${remainingFile.name}"
val destFile = File(trashDir, newName)
if (remainingFile.renameTo(destFile)) {
movedCount++
}
}
}
// 4. 메인 스레드에서 결과 메시지 출력 및 리스트 갱신
withContext(Dispatchers.Main) {
if (movedCount > 0 || deletedFolderCount > 0) {
val msg = buildString {
if (movedCount > 0) append("${movedCount}개의 파일을 폴더로 정리했습니다.\n")
if (deletedFolderCount > 0) append("${deletedFolderCount}개의 빈 폴더를 삭제했습니다.")
}.trim()
// [단계 4] 빈 폴더 싹쓸이 (Bottom-Up)
var deletedFolderCount = 0
rootDir.walkBottomUp().forEach { dir ->
if (dir != rootDir && dir.isDirectory) {
// 보호된 폴더는 비어있어도 삭제 안 함
if (dir.parentFile == rootDir && protectedDirs.contains(dir.name)) return@forEach
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
// 파일 이동이나 폴더 삭제가 하나라도 일어났다면 현재 화면 새로고침
loadFiles()
} else {
Toast.makeText(requireContext(), "정리할 파일이나 빈 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
if (dir.listFiles()?.isEmpty() == true) {
if (dir.delete()) deletedFolderCount++
}
}
}
withContext(Dispatchers.Main) {
val msg = "${movedCount}개 항목 정리(trash 포함) 및 ${deletedFolderCount}개 빈 폴더 삭제 완료"
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
loadFiles()
}
}
}
@@ -799,6 +979,20 @@ class CompletedFilesFragment : Fragment() {
private fun loadFiles() {
allFiles = if (currentDir.exists()) currentDir.listFiles()?.toList() ?: emptyList() else emptyList()
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
// 💡 1. 현재 진입한 폴더의 정렬 설정을 로드합니다.
loadFolderSortSetting(currentDir.absolutePath)
// 💡 2. 불러온 설정에 맞게 UI 컴포넌트(스피너, 화살표) 상태를 동기화합니다.
view?.let { root ->
val spinnerSort = root.findViewById<Spinner>(R.id.spinnerSort)
val tvSortOrder = root.findViewById<TextView>(R.id.tvSortOrder)
// 정렬타입 스피너 인덱스 찾아 바꾸기 (선택 시 이벤트 발생으로 applyFilterAndSort 자동 호출 차단 처리 포함)
spinnerSort.setSelection(currentSort.ordinal, false)
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
}
applyFilterAndSort()
}
@@ -807,6 +1001,25 @@ class CompletedFilesFragment : Fragment() {
if (!hidden) loadFiles()
}
val protectedFolders = setOf("Images", "Videos","Youtube")
private fun isProtectedFolder(file: File): Boolean {
if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
return true
}
return false
}
private fun isProtectedFile(file: File): Boolean {
if (file.isDirectory && file.parentFile == rootDir && protectedFolders.contains(file.name)) {
return true
}
val parent = file.parentFile
if (parent != null && parent.parentFile == rootDir && protectedFolders.contains(parent.name)) {
return true
}
return false
}
private fun applyFilterAndSort() {
// 💡 1. 데이터 소스 결정
val baseFiles = if (searchQuery.isNotEmpty()) {
@@ -28,6 +28,7 @@ import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContentProviderCompat.requireContext
import androidx.core.net.toUri
import androidx.core.view.isVisible
import bums.lunatic.launcher.BookmarkUploader
@@ -41,8 +42,10 @@ import bums.lunatic.launcher.home.tokiz.PortMessage
import bums.lunatic.launcher.model.Dotax
import bums.lunatic.launcher.model.DotaxArticles
import bums.lunatic.launcher.model.getRssData
import bums.lunatic.launcher.player.PlayerActivity
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CommonUtils
import bums.lunatic.launcher.utils.FileUtils
import bums.lunatic.launcher.utils.SimpleFingerGestures
import bums.lunatic.launcher.workers.TorrentService
import bums.lunatic.launcher.workers.WorkersDb
@@ -53,12 +56,16 @@ import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.jsoup.Jsoup
import org.mozilla.gecko.util.ThreadUtils
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate
@@ -129,6 +136,10 @@ open class GeckoWeb @JvmOverloads constructor(
}
}
fun scrollTop() {
sendJsonMsg("SCROLL_TOP")
}
// UI & State
var decoViews = arrayListOf<View>()
var progress: ProgressBar? = null
@@ -248,18 +259,21 @@ open class GeckoWeb @JvmOverloads constructor(
}
}
fun checkIfDownloadable(firstUrl: String, forMusic: Boolean = false) {
var checkIfDownloadableJob : Job? = null
suspend fun checkIfDownloadable(firstUrl: String, forMusic: Boolean = false) {
if (firstUrl.startsWith("about")) {
return
}
val cleanUrl = cleanYoutubeUrl(firstUrl) // ← 추가!
decoViews.firstOrNull { it.id == R.id.btn_dl_video }?.let {
it.setOnClickListener {}
it.visibility = GONE
CoroutineScope(Dispatchers.Main).launch {
decoViews.firstOrNull { it.id == R.id.btn_dl_video }?.let {
it.setOnClickListener {}
it.visibility = GONE
}
}
CoroutineScope(Dispatchers.IO).launch {
checkIfDownloadableJob?.cancelAndJoin()
checkIfDownloadableJob = CoroutineScope(Dispatchers.IO).launch {
try {
val request = YoutubeDLRequest(cleanUrl).apply {
// 1. yt-dlp 업데이트 강제 (가장 중요!)
@@ -269,17 +283,18 @@ open class GeckoWeb @JvmOverloads constructor(
val cookieFile = File(context.cacheDir, "cookies.txt")
addOption("--cookies", "${cookieFile.absolutePath}")
if (forMusic) {
addOption("-x") // 오디오 추출
addOption("--audio-format", "mp3")
// YouTube Music 전용 extractor arg 추가
addOption("--extractor-args", "youtube:player_client=web_music,android")
// 음악 스트림 우선 선택
addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
} else {
// if (forMusic) {
// addOption("-x") // 오디오 추출
// addOption("--audio-format", "mp3")
// // YouTube Music 전용 extractor arg 추가
// addOption("--extractor-args", "youtube:player_client=web_music,android")
// // 음악 스트림 우선 선택
// addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
// } else {
// 일반 영상용 기본 포맷
addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
}
addOption("-f", "best[ext=mp4]")
// addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
// }
// 디버깅용 (테스트 후 제거)
@@ -297,29 +312,6 @@ open class GeckoWeb @JvmOverloads constructor(
}
} catch (e: Exception) {
Blog.LOGE("Download Check Error", e)
// val msg = e.message ?: ""
// if (msg.contains("parse video information") && !lastCheckUrlS.contains(cleanUrl)) {
// CoroutineScope(Dispatchers.Main).launch {
// CoroutineScope(Dispatchers.Main).launch {
// decoViews.firstOrNull { it.id == R.id.btn_dl_video }?.let { view ->
// view.setOnClickListener {
// AlertDialog.Builder(context)
// .setTitle("음악 전용으로 재시도?")
// .setMessage("parse 에러 발생. forMusic=true로 재시도합니다.")
// .setPositiveButton("오키") { _, _ ->
// checkIfDownloadable(cleanUrl, true)
// }
// .setNegativeButton("취소", null)
// .show()
// lastCheckUrlS.add(cleanUrl)
// }
// }
// }
//
// }
// }
}
}
}
@@ -344,7 +336,15 @@ open class GeckoWeb @JvmOverloads constructor(
}
override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
if (response.uri.contains(".apk")) return
val url = response.uri
// 💡 .srt 파일인 경우 플레이어 로직과 연결
if (url.endsWith(".srt", ignoreCase = true) || url.contains("download")) {
(context as? PlayerActivity)?.let { player ->
player.downloadSubtitle(url)
}
} else {
downloadFile(response.uri)
}
}
override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, element: GeckoSession.ContentDelegate.ContextElement) {
val pageUrl = element.baseUri ?: lastedUrl ?: return
@@ -353,12 +353,10 @@ open class GeckoWeb @JvmOverloads constructor(
if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE ||
element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO) {
showContextMenu(pageUrl, mediaUrl)
// 로그인 확인 후 메뉴 표시
BookmarkUploader.loginAndGetToken(
userId = "lunaticbum", userPw = "VioPup*383",
onSuccess = { showContextMenu(pageUrl, mediaUrl); context.toast("로그인 성공") },
onError = { context.toast("로그인 실패: $it") }
)
} else {
super.onContextMenu(session, screenX, screenY, element)
}
@@ -377,7 +375,16 @@ open class GeckoWeb @JvmOverloads constructor(
// [Navigation Delegate]
private val navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onLoadRequest(
session: GeckoSession,
request: GeckoSession.NavigationDelegate.LoadRequest
): GeckoResult<AllowOrDeny?>? {
if (request.target != 1 && request.uri.contains("workupload")) {
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(lastedUrl), request.uri)
return null
}
return super.onLoadRequest(session, request)
}
override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
Uri.parse(uri)?.let {
@@ -391,6 +398,7 @@ open class GeckoWeb @JvmOverloads constructor(
showNewSessionDialog(uri)
}
}
return super.onNewSession(session, uri)
}
@@ -398,7 +406,6 @@ open class GeckoWeb @JvmOverloads constructor(
url?.let {
if (it.contains(getFilterF()) && privateMode) this@GeckoWeb.visibility = View.INVISIBLE
lastedUrl = if (it.split("//").size > 1) it.replace("//", "/").replace("https:/", "https://") else it
checkIfDownloadable(it)
onLocationChangeCallback?.invoke(it) // 외부 콜백
}
@@ -435,7 +442,7 @@ open class GeckoWeb @JvmOverloads constructor(
}
override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
Blog.LOGE("onCanGoBack $canGoBack ${session}")
// Blog.LOGE("onCanGoBack $canGoBack ${session}")
this@GeckoWeb.canGoBack = canGoBack
}
}
@@ -469,21 +476,20 @@ open class GeckoWeb @JvmOverloads constructor(
saveCurrentSessionState()
}
onPageStartCallback?.invoke(url)
// Blog.LOGE("onPageStart $url ${session}")
checkIfDownloadable(url)
}
override fun onPageStop(session: GeckoSession, success: Boolean) {
onPageStopCallback?.invoke(success)
if (success) {
saveCurrentSessionState()
}
Blog.LOGE("onPageStop $success ${session}")
// Blog.LOGE("onPageStop $success ${session}")
}
override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
lastSessionState = sessionState
onSessionStateChangeCallback?.invoke(sessionState)
Blog.LOGE("onSessionStateChange $sessionState ${session}")
saveCurrentSessionState()
// Blog.LOGE("onSessionStateChange $sessionState ${session}")
}
}
private var lastSessionState: GeckoSession.SessionState? = null
@@ -552,10 +558,10 @@ open class GeckoWeb @JvmOverloads constructor(
.allowJavascript(true)
.contextId("JUST_ONE")
.usePrivateMode(false)
.build()
val session = GeckoSession(sessionSettings)
session.open(runtime)
this.setSession(session)
@@ -631,8 +637,42 @@ open class GeckoWeb @JvmOverloads constructor(
null
}
}
private fun downloadSubtitleWithReferer(url: String, referer: String?) {
// 영상 파일명과 매칭하기 위해 PlayerActivity의 videoPath 정보 활용 필요
val filename = "downloaded_subtitle.srt"
val savePath = File(context.getExternalFilesDir(null), filename)
Thread {
try {
val request = Request.Builder()
.url(url)
.addHeader("Referer", referer ?: "")
.build()
val response = OkHttpClient().newCall(request).execute()
if (response.isSuccessful) {
response.body?.byteStream()?.use { input ->
FileOutputStream(savePath).use { output -> input.copyTo(output) }
}
post { context.toast("자막 다운로드 완료!") }
// 이후 PlayerActivity에 알림을 주어 자막 로드 실행
}
} catch (e: Exception) { e.printStackTrace() }
}.start()
}
private fun handlePortMessage(msg: PortMessage) {
when (msg.type) {
"SUBTITLE_LIST_RESULT" -> {
(context as? PlayerActivity)?.let { player ->
Blog.LOGE("msg.subTitles ${msg.subTitles}")
player.runOnUiThread {
player.showDownSubtitleSelectionDialog(msg.subTitles)
}
}
}
"COOKIES_REPORT"-> {
Blog.LOGE("${msg.value} -> ${msg.url}")
currentCookieString = msg.value ?: ""
@@ -640,9 +680,12 @@ open class GeckoWeb @JvmOverloads constructor(
CoroutineScope(Dispatchers.IO).launch {
saveYoutubeCookiesToFile()
}
CoroutineScope(Dispatchers.IO).launch {
checkIfDownloadable(currentCookieUrlString)
}
}
"SCROLL_STATE" -> {
Blog.LOGE("${msg.type} : ${msg.value}")
// Blog.LOGE("${msg.type} : ${msg.value}")
scrollState = msg.value?.toInt() ?: 0
}
"allImagesFound" -> lastedUrl?.let { startBookmarkSaveProcessForMultipleImages(it, msg.urls) }
@@ -691,7 +734,7 @@ open class GeckoWeb @JvmOverloads constructor(
fun sendSearchDo() = sendJsonMsg("searchDo")
fun saveMd(fast: Boolean? = false) = sendJsonMsg("saveContent", "fast" to fast)
private fun sendJsonMsg(type: String, vararg params: Pair<String, Any?>) {
fun sendJsonMsg(type: String, vararg params: Pair<String, Any?>) {
val json = JSONObject().put("type", type)
params.forEach { json.put(it.first, it.second) }
mPort?.postMessage(json)
@@ -782,22 +825,46 @@ open class GeckoWeb @JvmOverloads constructor(
// Dialog Helpers
private fun showNewSessionDialog(uri: String) {
AlertDialog.Builder(context)
.setTitle("Move To\n$uri")
.setPositiveButton("브라우저로 이동") { _, _ ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply { flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP })
if (uri.startsWith("magnet:?")) {
val intent = Intent(context, TorrentService::class.java).apply {
putExtra("EXTRA_MAGNET_URI", uri)
}
.setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) }
.setNegativeButton(android.R.string.cancel, null)
.show()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} else {
AlertDialog.Builder(context)
.setTitle("Move To\n$uri")
.setPositiveButton("브라우저로 이동") { _, _ ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP
})
}
.setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
private fun showContextMenu(pageUrl: String, mediaUrl: String) {
val menuItems = arrayOf("이 미디어만 북마크", "페이지의 모든 이미지 북마크", "다운로드")
AlertDialog.Builder(context).setTitle("작업 선택").setItems(menuItems) { _, which ->
when (menuItems[which]) {
"이 미디어만 북마크" -> startBookmarkSaveProcessForSingleImage(pageUrl, mediaUrl)
"페이지의 모든 이미지 북마크" -> sendJsonMsg("fetchAllImages", "targetSrc" to mediaUrl)
"이 미디어만 북마크" ->
BookmarkUploader.loginAndGetToken(
userId = "lunaticbum", userPw = "VioPup*383",
onSuccess = { context.toast("로그인 성공");startBookmarkSaveProcessForSingleImage(pageUrl, mediaUrl) },
onError = { context.toast("로그인 실패: $it") }
)
"페이지의 모든 이미지 북마크" ->
BookmarkUploader.loginAndGetToken(
userId = "lunaticbum", userPw = "VioPup*383",
onSuccess = { context.toast("로그인 성공");sendJsonMsg("fetchAllImages", "targetSrc" to mediaUrl) },
onError = { context.toast("로그인 실패: $it") }
)
"다운로드" -> CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), mediaUrl)
}
}.show()
@@ -858,6 +925,13 @@ open class GeckoWeb @JvmOverloads constructor(
session?.setActive(true)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return false
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return false
}
// dp 변환 확장 함수
private fun Int.dpToPx(): Int = (this * context.resources.displayMetrics.density).toInt()
fun prev10() {
@@ -0,0 +1,258 @@
package bums.lunatic.launcher.home
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import bums.lunatic.launcher.R
import bums.lunatic.launcher.model.ExpressionItem
import bums.lunatic.launcher.workers.WorkersDb
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import io.realm.kotlin.ext.query
class LearningFragment : Fragment() {
private lateinit var recyclerExpressions: RecyclerView
private lateinit var layoutTopics: ViewGroup
private lateinit var layoutLanguages: ViewGroup
private val adapter = ExpressionAdapter()
private var currentTopic: String = ""
private var currentLang: String = "EN" // 기본 영어
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_learning, container, false)
recyclerExpressions = view.findViewById(R.id.recycler_expressions)
layoutTopics = view.findViewById(R.id.layout_topics)
layoutLanguages = view.findViewById(R.id.layout_languages)
recyclerExpressions.layoutManager = LinearLayoutManager(requireContext())
recyclerExpressions.adapter = adapter
setupFilters()
loadExpressions()
return view
}
private fun setupFilters() {
val realm = WorkersDb.getRealm()
// 1. 언어 필터 세팅
val langs = listOf(
"EN" to "영어",
"ES" to "스페인어",
"JA" to "일본어",
"ZH" to "중국어" // 추가된 부분!
)
layoutLanguages.removeAllViews()
langs.forEach { (code, name) ->
val btn = Button(requireContext()).apply {
text = name
setBackgroundColor(if (currentLang == code) Color.DKGRAY else Color.TRANSPARENT)
setTextColor(if (currentLang == code) Color.WHITE else Color.LTGRAY)
setOnClickListener {
currentLang = code
setupFilters() // 색상 갱신을 위해 재호출
loadExpressions()
}
}
layoutLanguages.addView(btn)
}
// 2. 주제(Topic) 필터 세팅 (DB에서 고유값 가져오기)
// 참고: Realm Kotlin은 distinct를 바로 지원하진 않으므로 전체를 가져와서 Set으로 필터링합니다.
val allItems = realm.query<ExpressionItem>().find()
val topics = allItems.map { it.topic }.distinct()
layoutTopics.removeAllViews()
// '전체' 보기 버튼
val allBtn = Button(requireContext()).apply {
text = "전체"
setBackgroundColor(if (currentTopic.isEmpty()) Color.DKGRAY else Color.TRANSPARENT)
setTextColor(if (currentTopic.isEmpty()) Color.WHITE else Color.LTGRAY)
setOnClickListener {
currentTopic = ""
setupFilters()
loadExpressions()
}
}
layoutTopics.addView(allBtn)
topics.forEach { topic ->
val btn = Button(requireContext()).apply {
text = topic
setBackgroundColor(if (currentTopic == topic) Color.DKGRAY else Color.TRANSPARENT)
setTextColor(if (currentTopic == topic) Color.WHITE else Color.LTGRAY)
setOnClickListener {
currentTopic = topic
setupFilters()
loadExpressions()
}
}
layoutTopics.addView(btn)
}
}
private fun loadExpressions() {
val realm = WorkersDb.getRealm()
var query = realm.query<ExpressionItem>("lang == $0", currentLang)
if (currentTopic.isNotEmpty()) {
query = query.query("topic == $0", currentTopic)
}
// 최신순으로 정렬
val results = query.sort("timestamp", io.realm.kotlin.query.Sort.DESCENDING).find()
adapter.submitList(results.toList())
}
// --- 내부 RecyclerView 어댑터 ---
inner class ExpressionAdapter : RecyclerView.Adapter<ExpressionAdapter.ViewHolder>() {
private var items: List<ExpressionItem> = emptyList()
fun submitList(newItems: List<ExpressionItem>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// 1. 전체 카드 컨테이너
val view = LinearLayout(parent.context).apply {
orientation = LinearLayout.VERTICAL
setPadding(40, 40, 40, 40)
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, 24) }
// 모서리가 둥근 반투명 배경 효과 (간단히 Color 설정)
setBackgroundColor(Color.parseColor("#22FFFFFF"))
}
// 2. 문장 텍스트
val mainText = TextView(parent.context).apply {
textSize = 22f
setTextColor(Color.WHITE)
setTypeface(null, android.graphics.Typeface.BOLD)
}
// 3. 발음 텍스트
val pronText = TextView(parent.context).apply {
textSize = 15f
setTextColor(Color.parseColor("#80DEEA")) // Cyan 계열
setPadding(0, 12, 0, 0)
}
// 4. 뜻 텍스트
val subText = TextView(parent.context).apply {
textSize = 14f
setTextColor(Color.LTGRAY)
setPadding(0, 8, 0, 24)
}
// 💡 5. 단어들을 담을 ChipGroup (자동 줄바꿈 지원)
val chipGroup = ChipGroup(parent.context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
isSingleLine = false // 공간이 부족하면 아래로 자동 줄바꿈
chipSpacingHorizontal = 16
chipSpacingVertical = 16
}
view.addView(mainText)
view.addView(pronText)
view.addView(subText)
view.addView(chipGroup) // ChipGroup 추가
return ViewHolder(view, mainText, pronText, subText, chipGroup)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
// 언어별 폰트 느낌 살리기 (옵션)
when (item.lang) {
"ZH" -> holder.mainText.textLocale = java.util.Locale.SIMPLIFIED_CHINESE
"JA" -> holder.mainText.textLocale = java.util.Locale.JAPAN
else -> holder.mainText.textLocale = java.util.Locale.US
}
holder.mainText.text = item.mainText
holder.pronText.text = "[${item.pronunciation}]"
holder.subText.text = item.subText
// 💡 단어 Chip 생성 로직
holder.chipGroup.removeAllViews() // 재사용 방지를 위해 기존 칩 지우기
item.words.forEach { wordItem ->
val chip = Chip(holder.itemView.context).apply {
text = wordItem.word
isCheckable = false
isClickable = true
setChipBackgroundColorResource(android.R.color.darker_gray)
setTextColor(Color.WHITE)
var isShowingMeaning = false
setOnClickListener {
isShowingMeaning = !isShowingMeaning
if (isShowingMeaning) {
// 💡 터치 시: 단어 [발음] : 뜻 형태로 보여줌
text = "${wordItem.word} [${wordItem.pronunciation}] : ${wordItem.meaning}"
setChipBackgroundColorResource(android.R.color.holo_blue_dark)
} else {
// 다시 터치 시: 원단어만 보임
text = wordItem.word
setChipBackgroundColorResource(android.R.color.darker_gray)
}
}
setOnLongClickListener {
// 언어별 사전 URL 분기 처리
val baseUrl = when (item.lang) {
"EN" -> "https://en.dict.naver.com/#/search?query="
"JA" -> "https://ja.dict.naver.com/#/search?query="
"ZH" -> "https://zh.dict.naver.com/#/search?query="
"ES" -> "https://dict.naver.com/eskodict/#/search?query="
else -> "https://dict.naver.com/search.dict?dicQuery=" // 예비용 통합 검색
}
val searchUrl = baseUrl + wordItem.word
// 브라우저 띄우기 Intent 생성
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(searchUrl))
// 혹시 액티비티 밖에서 실행될 경우를 대비한 플래그 (어댑터 안에서는 보통 필요 없지만 안전하게 추가)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
holder.itemView.context.startActivity(intent)
// true를 반환해야 일반 클릭(setOnClickListener)이 동시에 실행되지 않음
true
}
}
holder.chipGroup.addView(chip)
}
}
override fun getItemCount() = items.size
inner class ViewHolder(
view: View,
val mainText: TextView,
val pronText: TextView,
val subText: TextView,
val chipGroup: ChipGroup // ChipGroup 참조 추가
) : RecyclerView.ViewHolder(view)
}
}
@@ -19,6 +19,8 @@ import android.view.KeyEvent.KEYCODE_BUTTON_START
import android.view.KeyEvent.KEYCODE_BUTTON_X
import android.view.KeyEvent.KEYCODE_BUTTON_Y
import android.view.KeyEvent.KEYCODE_DPAD_DOWN
import android.view.KeyEvent.KEYCODE_DPAD_LEFT
import android.view.KeyEvent.KEYCODE_DPAD_RIGHT
import android.view.KeyEvent.KEYCODE_DPAD_UP
import android.view.KeyEvent.KEYCODE_MEDIA_PAUSE
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY
@@ -54,6 +56,8 @@ import bums.lunatic.launcher.home.tokiz.TokiFragment
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.beforeDay
import bums.lunatic.launcher.workers.WorkersDb
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.Wearable
import com.google.android.material.color.DynamicColors
import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL
@@ -121,85 +125,146 @@ open class NeoRssActivity : CommonActivity() {
var lastAction = MotionEvent.ACTION_HOVER_EXIT
private val messageListener = MessageClient.OnMessageReceivedListener { messageEvent ->
val currentFragment = targetFragment ?: supportFragmentManager.fragments.find { it.isVisible }
if (currentFragment is RemoteGestureFragment && currentFragment.isRemoteEnabled) {
// when(keyType) {
// "UP_ARROW"->{currentFragment.onRemoteUp(isDouble)}
// "DOWN_ARROW"->{currentFragment.onRemoteDown(isDouble)}
//
// "LEFT_ARROW"->{currentFragment.onRemoteLeft(isDouble)}
// "CENTER_SINGLE_TAP" -> { currentFragment.onRemoteCenterClick() }
// "CENTER_DOUBLE_TAP" -> { currentFragment.onRemoteCenterDoubleClick() }
// }
// when(currentFragment) {
// is RssHome ->{
// if (currentFragment.binding.layoutRssSummary.root.isVisible) {
// currentFragment.openGecko(rssData = currentFragment.randomOrNull())
// } else {
// currentFragment.doNextPage()
// }
// }
// is TokiFragment -> {
// currentFragment.back()
// }
// is CompletedFilesFragment -> {
// currentFragment.backPress()
// }
// else -> {
//// showContents(R.id.close)
// }
// }
when (messageEvent.path) {
"/gesture/right",
"/gesture/next" -> {
when(currentFragment) {
is RssHome ->{
if (currentFragment.binding.layoutRssSummary.root.isVisible) {
currentFragment.openGecko(rssData = currentFragment.randomOrNull())
} else {
currentFragment.doNextPage()
}
}
is TokiFragment -> {
currentFragment.back()
}
is CompletedFilesFragment -> {
currentFragment.backPress()
}
else -> {
// showContents(R.id.close)
}
}
}
"/gesture/left",
"/gesture/prev" -> {
currentFragment.onRemoteLeft(false)
}
"/gesture/up" ->{}
"/gesture/down" ->{}
}
}
}
override fun onKeyLongPress(keyCode: Int, ev: KeyEvent?): Boolean {
Blog.LOGE("keyEvent >>>>> ${ev?.device?.name}:${keyCode}: onKeyLongPress >>> ${ev} ")
return super.onKeyLongPress(keyCode, ev)
}
override fun dispatchKeyEvent(ev: KeyEvent): Boolean {
Blog.LOGE("keyEvent >>>>> ${ev?.device?.name}:dispatchKeyEvent >>> ${ev} ")
if (ev?.device?.name?.contains("JX-05") == true) {
// (ev.action?.equals(KeyEvent.ACTION_DOWN) == true || ev.action?.equals(KeyEvent.ACTION_UP) == true)
if (ev.action?.equals(KeyEvent.ACTION_UP) == true) {
when(ev.keyCode) {
KEYCODE_MEDIA_PLAY_PAUSE->{
onRemoteKeyDetected("RIGHT_ARROW", true)
}
KEYCODE_MEDIA_PAUSE->{
when(ev.keyCode) {
KEYCODE_MEDIA_PLAY_PAUSE->{
onRemoteKeyDetected("RIGHT_ARROW", true)
}
KEYCODE_MEDIA_PAUSE->{
// onRemoteKeyDetected("LEFT_ARROW", true)
}
KEYCODE_VOLUME_UP->{
onRemoteKeyDetected("UP_ARROW", true)
}
KEYCODE_VOLUME_DOWN->{
onRemoteKeyDetected("DOWN_ARROW", true)
}
}}
}
KEYCODE_VOLUME_UP->{
onRemoteKeyDetected("UP_ARROW", true)
}
KEYCODE_VOLUME_DOWN->{
onRemoteKeyDetected("DOWN_ARROW", true)
}
}}
return true
}else
if (ev?.device?.name?.contains("SM-031N Mouse") == true) {
when(ev.action) {
ACTION_UP -> {
Blog.LOGE("dispatch dispatchKeyEvent>>> ${ev}")
when(ev.keyCode) {
KEYCODE_BUTTON_Y->{
when(ev.action) {
ACTION_UP -> {
when(ev.keyCode) {
KEYCODE_BUTTON_Y->{
}
KEYCODE_BUTTON_X->{
}
KEYCODE_BUTTON_X->{
}
KEYCODE_BUTTON_A->{
WorkersDb.getRealm().apply {
writeBlocking {
}
KEYCODE_BUTTON_A->{
WorkersDb.getRealm().apply {
writeBlocking {
}
}
}
}
KEYCODE_BUTTON_B->{
KEYCODE_BUTTON_B->{
}
KEYCODE_DPAD_DOWN->{
}
KEYCODE_DPAD_DOWN->{
}
KEYCODE_DPAD_UP->{
}
KEYCODE_DPAD_UP->{
}
KEYCODE_BUTTON_START->{
onClickCenterButton()
}
KEYCODE_BUTTON_SELECT->{
WorkersDb.getRealm().apply {
writeBlocking {
}
KEYCODE_BUTTON_START->{
onClickCenterButton()
}
KEYCODE_BUTTON_SELECT->{
WorkersDb.getRealm().apply {
writeBlocking {
}
}
}
else -> {}
}
else -> {}
}
else->{}
}
return true
}
else {
val currentFragment = targetFragment ?: supportFragmentManager.fragments.find { it.isAdded && (!it.isHidden || it.isVisible) }
if (currentFragment is KeyEventHandler) {
if (currentFragment.onKeyEvent(ev)) {
return true
}
}
else->{}
else { return super.dispatchKeyEvent(ev) }
}
return true
}
else {
val currentFragment = supportFragmentManager.fragments.find { it.isAdded && (!it.isHidden || it.isVisible) }
if (currentFragment is KeyEventHandler) {
if (currentFragment.onKeyEvent(ev)) {
return true
}
}
else { return super.dispatchKeyEvent(ev) }
}
return super.dispatchKeyEvent(ev)
}
@@ -265,7 +330,7 @@ open class NeoRssActivity : CommonActivity() {
private fun onRemoteKeyDetected(keyType: String, isDouble : Boolean = false) {
Blog.LOGE("Remote Mapping Success: $keyType")
val currentFragment = supportFragmentManager.fragments.find { it.isVisible }
val currentFragment = targetFragment ?: supportFragmentManager.fragments.find { it.isVisible }
if (currentFragment is RemoteGestureFragment && currentFragment.isRemoteEnabled) {
when(keyType) {
"UP_ARROW"->{currentFragment.onRemoteUp(isDouble)}
@@ -279,7 +344,7 @@ open class NeoRssActivity : CommonActivity() {
when(keyType) {
"UP_ARROW"->{showContents(R.id.feeds)}
"DOWN_ARROW"->{showContents(R.id.close)}
"RIGHT_ARROW"->{showContents(R.id.books)}
// "RIGHT_ARROW"->{showContents(R.id.books)}
"LEFT_ARROW"->{ }
"CENTER_SINGLE_TAP" -> { }
"CENTER_DOUBLE_TAP" -> { showContents(R.id.btn_torrent) }
@@ -288,7 +353,7 @@ open class NeoRssActivity : CommonActivity() {
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Blog.LOGE("keyEvent >>>>> dispatchTouchEvent ${ev}")
// Blog.LOGE("keyEvent >>>>> dispatchTouchEvent ${ev}")
if (ev?.device?.name?.contains("JX-05") == true) {
// 리모컨 이벤트인 경우 제스처 디텍터에 위임
if (remoteGestureDetector.onTouchEvent(ev)) {
@@ -317,9 +382,34 @@ open class NeoRssActivity : CommonActivity() {
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
Blog.LOGE("onKeyUp >> $keyCode $event")
if ("Virtual".equals(event?.device?.name)) {
val currentFragment = targetFragment ?: supportFragmentManager.fragments.find { it.isVisible }
if (currentFragment is RemoteGestureFragment && currentFragment.isRemoteEnabled) {
when (keyCode) {
KEYCODE_DPAD_UP -> {
currentFragment.onRemoteUp(false)
}
KEYCODE_DPAD_DOWN -> {
currentFragment.onRemoteDown(false)
}
KEYCODE_DPAD_LEFT -> {
currentFragment.onRemoteLeft(false)
}
KEYCODE_DPAD_RIGHT -> {
currentFragment.onRemoteRight(false)
}
}
}
}
return super.onKeyUp(keyCode, event)
}
override fun dispatchGenericMotionEvent(ev: MotionEvent?): Boolean {
// Blog.LOGE("keyEvent >>>>> dispatchGenericMotionEvent ${ev?.device?.name ?: ""}: ${ev} ")
if ("Virtual".equals(ev?.device?.name)) return true
Blog.LOGE("keyEvent >>>>> dispatchGenericMotionEvent ${ev?.device?.name ?: ""}: ${ev} ")
if (ev?.device?.name?.contains("BLE-M3") == true) {
@@ -433,6 +523,7 @@ open class NeoRssActivity : CommonActivity() {
super.onCreate(savedInstanceState)
try {
YoutubeDL.getInstance().init(this)
FFmpeg.getInstance().init(this)
CoroutineScope(Dispatchers.IO).launch {
try {
@@ -496,7 +587,7 @@ open class NeoRssActivity : CommonActivity() {
override fun onPause() {
super.onPause()
Wearable.getMessageClient(this).removeListener(messageListener)
}
@@ -507,22 +598,22 @@ open class NeoRssActivity : CommonActivity() {
private var lastTouchX = 0f
private var lastTouchY = 0f
var targetFragment : Fragment? = null
// private val fragmentMap = mutableMapOf<Int, Fragment>()
fun showContents(id : Int) {
if (id == View.NO_ID) {
Blog.LOGE("무효한 ID(-1) 호출로 인해 showContents를 취소합니다.")
// Blog.LOGE("무효한 ID(-1) 호출로 인해 showContents를 취소합니다.")
return
}
Blog.LOGE("targetFragment id ${id}")
// Blog.LOGE("targetFragment id ${id}")
// 1. 모든 관련 레이어 가시성 확보
binding.fragmentContainer.isVisible = true
val transaction = supportFragmentManager.beginTransaction()
val tagKey = "TAG_$id"
Blog.LOGE("targetFragment id ${id} tagKey ${tagKey}")
// Blog.LOGE("targetFragment id ${id} tagKey ${tagKey}")
// 2. 매니저에 등록된 모든 프래그먼트를 찾아서 숨김 (중복 방지)
val allFragments = supportFragmentManager.fragments
for (f in allFragments) {
@@ -532,28 +623,37 @@ open class NeoRssActivity : CommonActivity() {
}
// 3. 대상 프래그먼트 찾기 (findFragmentByTag 우선)
var targetFragment = supportFragmentManager.findFragmentByTag(tagKey)
targetFragment = supportFragmentManager.findFragmentByTag(tagKey)
if (targetFragment == null) {
// 처음 호출되는 메뉴라면 인스턴스 생성 및 추가
targetFragment = when(id) {
R.id.feeds -> RssHome()
R.id.books -> TokiFragment.newInstanceNovels()
R.id.webtoons -> TokiFragment.newInstanceWebtoons()
R.id.comics -> TokiFragment.newInstanceComics()
R.id.webtoons -> TokiFragment.newInstanceNovels()
// R.id.comics -> TokiFragment.newInstanceComics()
R.id.youtube -> TokiFragment.newInstanceYouTube()
R.id.perplexity -> TokiFragment.newInstancePerplexity()
// R.id.perplexity -> TokiFragment.newInstancePerplexity()
R.id.zzalbang -> BookmarkPagerFragment()
R.id.btn_x -> TokiFragment.newInstanceX()
R.id.btn_i -> TokiFragment.newInstanceI()
R.id.btn_btsearch -> TokiFragment.newInstanceMagnet()
// R.id.btn_img4 -> TokiFragment.newInstanceTumblr()
R.id.btn_img4 -> {
startActivity(Intent(this@NeoRssActivity, TranslatorActivity::class.java))
targetFragment
}
R.id.btn_img3 -> TokiFragment.newInstanceTumblr()
// R.id.btn_img2 -> TokiFragment.newInstancePixiv()
// R.id.btn_img1 -> TokiFragment.newInstanceArtStation()
R.id.btn_torrent -> TorrentListFragment()
R.id.btn_info -> SystemStatusFragment()
R.id.btn_completed_files -> CompletedFilesFragment()
R.id.btn_learn -> LearningFragment()
R.id.books -> {
startActivity(Intent(this@NeoRssActivity, WebReaderActivity::class.java))
finish()
return
}
R.id.close -> {
finish()
return
@@ -562,13 +662,16 @@ open class NeoRssActivity : CommonActivity() {
}
targetFragment?.let {
Blog.LOGE("targetFragment id ${id} key : ${tagKey}, instance ${targetFragment}")
// Blog.LOGE("targetFragment id ${id} key : ${tagKey}, instance ${targetFragment}")
transaction.add(R.id.fragment_container, it, tagKey)
}
} else {
Blog.LOGE("targetFragment id ${id} key : ${tagKey}, instance ${targetFragment}")
// Blog.LOGE("targetFragment id ${id} key : ${tagKey}, instance ${targetFragment}")
// 이미 생성된 프래그먼트가 있다면 다시 보여줌
transaction.show(targetFragment)
targetFragment?.let {
transaction.show(it)
}
}
transaction.commit()
@@ -603,6 +706,7 @@ open class NeoRssActivity : CommonActivity() {
override fun onResume() {
super.onResume()
Blog.LOGE("LauncherActivity onResume")
Wearable.getMessageClient(this).addListener(messageListener)
}
private fun openSearch() {
@@ -625,7 +729,7 @@ open class NeoRssActivity : CommonActivity() {
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Blog.LOGE("handleBackPress")
val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
val currentFragment = targetFragment ?: supportFragmentManager.findFragmentById(R.id.fragment_container)
if (currentFragment == null) showContents(R.id.close)
when(currentFragment) {
is RssHome ->{
@@ -169,7 +169,7 @@ internal class RssHome : RemoteGestureFragment() , KeyEventHandler {
val infoUpdate = Runnable { chooseAdpater() }
var result: RealmResults<WeatherForcast>? = null
val nomoreShowCount = 5
val nomoreShowCount = 1
fun rssStateVote() = (lasted?.filter { it.vote == true }?.size ?: -1) == (lasted?.size ?: 0)
var lasted: ArrayList<RssData> = arrayListOf()
var infosJob: Job? = null
@@ -534,6 +534,7 @@ internal class RssHome : RemoteGestureFragment() , KeyEventHandler {
}
@SuppressLint("ClickableViewAccessibility")
override fun onCreateView(
inflater: LayoutInflater,
@@ -739,7 +740,7 @@ internal class RssHome : RemoteGestureFragment() , KeyEventHandler {
// 일반 WebView라면: webView.onPause() 및 webView.pauseTimers()
} else {
// 💡 다시 나타날 때: 다시 시작
// binding.geckoWeb?.onResume()
binding.lunaticBrowser.geckoWeb?.onResume()
// 일반 WebView라면: webView.onResume() 및 webView.resumeTimers()
}
}
@@ -0,0 +1,174 @@
package bums.lunatic.launcher.home
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.util.Log
import android.view.View
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import com.google.mlkit.common.model.DownloadConditions
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 java.util.*
class TranslatorActivity : AppCompatActivity(), TextToSpeech.OnInitListener {
private lateinit var etInputText: EditText
private lateinit var spinnerTargetLang: Spinner
private lateinit var btnTranslate: Button
private lateinit var tvOutputText: TextView
private lateinit var layoutProgress: LinearLayout
private lateinit var tvSwap: TextView
private lateinit var tvTtsInput: TextView
private lateinit var tvTtsOutput: TextView
private lateinit var sbTtsSpeed: SeekBar
private lateinit var tvInputLangStatus: TextView
private var tts: TextToSpeech? = null
private var detectedSourceLangCode: String? = null
private var ttsSpeed: Float = 1.0f
private val targetLanguages = listOf(
Pair("한국어 🇰🇷", TranslateLanguage.KOREAN),
Pair("영어 🇺🇸", TranslateLanguage.ENGLISH),
Pair("일본어 🇯🇵", TranslateLanguage.JAPANESE),
Pair("중국어 🇨🇳", TranslateLanguage.CHINESE),
Pair("스페인어 🇪🇸", TranslateLanguage.SPANISH),
Pair("프랑스어 🇫🇷", TranslateLanguage.FRENCH)
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_translator)
tts = TextToSpeech(this, this)
initViews()
setupListeners()
}
private fun initViews() {
etInputText = findViewById(R.id.etInputText)
spinnerTargetLang = findViewById(R.id.spinnerTargetLang)
btnTranslate = findViewById(R.id.btnTranslate)
tvOutputText = findViewById(R.id.tvOutputText)
layoutProgress = findViewById(R.id.layoutProgress)
tvSwap = findViewById(R.id.tvSwap)
tvTtsInput = findViewById(R.id.tvTtsInput)
tvTtsOutput = findViewById(R.id.tvTtsOutput)
sbTtsSpeed = findViewById(R.id.sbTtsSpeed)
tvInputLangStatus = findViewById(R.id.tvInputLangStatus)
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, targetLanguages.map { it.first })
spinnerTargetLang.adapter = adapter
spinnerTargetLang.setSelection(1) // 기본 영어
}
private fun setupListeners() {
btnTranslate.setOnClickListener {
val text = etInputText.text.toString().trim()
if (text.isNotEmpty()) {
val targetCode = targetLanguages[spinnerTargetLang.selectedItemPosition].second
detectAndTranslate(text, targetCode)
}
}
tvSwap.setOnClickListener {
val input = etInputText.text.toString()
val output = tvOutputText.text.toString()
if (output.isNotEmpty() && !output.startsWith("번역")) {
etInputText.setText(output)
tvOutputText.text = input
detectedSourceLangCode?.let { source ->
val idx = targetLanguages.indexOfFirst { it.second == source }
if (idx != -1) spinnerTargetLang.setSelection(idx)
}
detectedSourceLangCode = null // 스왑 후에는 다시 감지 필요
}
}
sbTtsSpeed.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
// 0~200 범위를 0.1~2.0배속으로 변환 (progress 100이 1.0배속)
ttsSpeed = progress / 100f
if (ttsSpeed < 0.1f) ttsSpeed = 0.1f
tts?.setSpeechRate(ttsSpeed)
}
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
})
tvTtsInput.setOnClickListener { playTts(etInputText.text.toString(), detectedSourceLangCode) }
tvTtsOutput.setOnClickListener {
val code = targetLanguages[spinnerTargetLang.selectedItemPosition].second
playTts(tvOutputText.text.toString(), code)
}
}
private fun detectAndTranslate(text: String, targetLangCode: String) {
tvOutputText.text = "언어 감지 중..."
btnTranslate.isEnabled = false
LanguageIdentification.getClient().identifyLanguage(text)
.addOnSuccessListener { languageCode ->
detectedSourceLangCode = TranslateLanguage.fromLanguageTag(languageCode)
if (detectedSourceLangCode != null && languageCode != "und") {
tvInputLangStatus.text = "원본: ${languageCode.uppercase()}"
tvTtsInput.visibility = View.VISIBLE
performTranslation(text, detectedSourceLangCode!!, targetLangCode)
} else {
tvOutputText.text = "언어를 감지할 수 없습니다."
btnTranslate.isEnabled = true
}
}
}
private fun performTranslation(text: String, source: String, target: String) {
if (source == target) {
tvOutputText.text = text
btnTranslate.isEnabled = true
return
}
layoutProgress.visibility = View.VISIBLE
val options = TranslatorOptions.Builder().setSourceLanguage(source).setTargetLanguage(target).build()
val translator = Translation.getClient(options)
translator.downloadModelIfNeeded(DownloadConditions.Builder().build())
.addOnSuccessListener {
layoutProgress.visibility = View.GONE
translator.translate(text)
.addOnSuccessListener { translated ->
tvOutputText.text = translated
tvTtsOutput.visibility = View.VISIBLE
btnTranslate.isEnabled = true
}
.addOnFailureListener { btnTranslate.isEnabled = true }
}
.addOnFailureListener {
layoutProgress.visibility = View.GONE
btnTranslate.isEnabled = true
}
}
private fun playTts(text: String, langCode: String?) {
if (text.isEmpty() || langCode == null) return
val locale = when (langCode) {
TranslateLanguage.KOREAN -> Locale.KOREAN
TranslateLanguage.ENGLISH -> Locale.ENGLISH
TranslateLanguage.JAPANESE -> Locale.JAPANESE
TranslateLanguage.CHINESE -> Locale.CHINESE
else -> Locale.getDefault()
}
tts?.apply {
language = locale
setSpeechRate(ttsSpeed) // 현재 SeekBar에 설정된 속도 적용
speak(text, TextToSpeech.QUEUE_FLUSH, null, "ID")
}
}
override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) Log.d("TTS", "Ready") }
override fun onDestroy() { tts?.stop(); tts?.shutdown(); super.onDestroy() }
}
@@ -0,0 +1,291 @@
package bums.lunatic.launcher.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import androidx.core.net.toUri
import bums.lunatic.launcher.utils.Blog
class WebReaderActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var filePathCallback: ValueCallback<Array<Uri>>? = null
// 원본 서버 주소 (이 주소로 위조할 예정)
private val SERVER_URL = "https://git.lunaticbum.kr/reader.html"
private val BASE_URL = "https://git.lunaticbum.kr/"
// 백업 데이터를 임시로 저장할 변수
private var pendingBackupData: String? = null
// 1. 파일 열기 (업로드/복원용) 런처
// 1. 파일 열기 (업로드/복원용) 런처 - 다중 파일 지원으로 수정
private val fileChooserLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
var uriList: Array<Uri>? = null
// 다중 선택을 했을 경우 (clipData로 데이터가 들어옴)
if (data?.clipData != null) {
val count = data.clipData!!.itemCount
uriList = Array(count) { i ->
data.clipData!!.getItemAt(i).uri
}
}
// 단일 선택을 했을 경우 (data로 데이터가 들어옴)
else if (data?.data != null) {
uriList = arrayOf(data.data!!)
}
filePathCallback?.onReceiveValue(uriList)
} else {
filePathCallback?.onReceiveValue(null)
}
filePathCallback = null
}
private fun isNetworkConnected(): Boolean {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
}
// 2. 파일 저장 (백업용) 런처 - 안드로이드 표준 저장 탐색기 호출
private val fileSaveLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri: Uri? ->
uri?.let { saveUri ->
pendingBackupData?.let { jsonData ->
try {
// 선택한 경로에 백업 데이터 쓰기
contentResolver.openOutputStream(saveUri)?.use { outputStream ->
outputStream.write(jsonData.toByteArray(Charsets.UTF_8))
}
Toast.makeText(this, "서재 백업이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "백업 저장에 실패했습니다.", Toast.LENGTH_SHORT).show()
e.printStackTrace()
}
}
}
pendingBackupData = null // 작업 후 초기화
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_wr)
webView = findViewById(R.id.webView)
setupWebView()
// 🌟 수정된 부분: 인터넷 상태를 먼저 체크합니다!
if (isNetworkConnected()) {
// 온라인이면 서버로 접속
webView.loadUrl(SERVER_URL)
} else {
// 오프라인이면 즉시 로컬 에셋(위조 모드)으로 실행
loadLocalHtmlAsServer()
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
var uri : Uri? = webView.url?.toUri()
if (webView.canGoBack() && (uri?.queryParameterNames?.size ?: 0) > 0) {
webView.loadUrl(SERVER_URL)
} else {
this@WebReaderActivity.finish()
}
}
})
}
private fun setupWebView() {
webView.settings.apply {
cacheMode = WebSettings.LOAD_NO_CACHE
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
useWideViewPort = true
loadWithOverviewMode = true
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url.toString()
// 1. 현재 오프라인 상태이고
// 2. 이동하려는 주소가 내 서버 주소(예: reader.html?id=123)일 때
if (!isNetworkConnected() && url.startsWith(BASE_URL)) {
// 실제 네트워크 통신을 차단하고, 해당 URL로 위조된 로컬 HTML을 다시 로드
loadLocalHtmlAsServer(url)
// return true를 하면 웹뷰가 자체적인 네트워크 통신을 진행하지 않음
return true
}
return super.shouldOverrideUrlLoading(view, request)
}
// 🌐 에러 감지 1: 비행기 모드, 인터넷 끊김 등
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
if (request?.isForMainFrame == true) {
loadLocalHtmlAsServer()
}
}
// 🌐 에러 감지 2: 서버 점검, 404/500 에러 등
override fun onReceivedHttpError(
view: WebView?,
request: WebResourceRequest?,
errorResponse: WebResourceResponse?
) {
super.onReceivedHttpError(view, request, errorResponse)
if (request?.isForMainFrame == true) {
loadLocalHtmlAsServer()
}
}
// 🚨 핵심 포인트: JS 파일 가로채기
// HTML이 로컬에서 로드되더라도 주소가 위조되었기 때문에 JS파일도 서버에 요청해버립니다.
// 이를 중간에 가로채서 로컬 assets 폴더의 jszip.min.js를 던져줍니다.
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
val url = request?.url.toString()
if (url.contains("jszip.min.js")) {
return try {
val inputStream = assets.open("jszip.min.js")
WebResourceResponse("text/javascript", "UTF-8", inputStream)
} catch (e: Exception) {
null
}
}
return super.shouldInterceptRequest(view, request)
}
}
// 3. 웹 -> 안드로이드 통신을 위한 브릿지 연결
webView.addJavascriptInterface(WebAppInterface(), "AndroidBridge")
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
this@WebReaderActivity.filePathCallback?.onReceiveValue(null)
this@WebReaderActivity.filePathCallback = filePathCallback
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
// ⭐ 추가된 핵심 코드: 웹에서 multiple 속성을 보냈는지 확인 후 안드로이드 파일 탐색기에도 적용
if (fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
}
fileChooserLauncher.launch(intent)
return true
}
}
}
/**
* assets 폴더의 html 파일을 읽어와서 서버 주소로 위조하여 웹뷰에 로드합니다.
*/
private fun loadLocalHtmlAsServer(targetUrl: String = SERVER_URL) {
try {
Blog.LOGE("targetUrl ${targetUrl}")
val htmlString = assets.open("reader.html").bufferedReader().use { it.readText() }
webView.loadDataWithBaseURL(
targetUrl,
htmlString,
"text/html",
"UTF-8",
targetUrl // 🌟 핵심: ?id= 파라미터가 붙은 주소로 히스토리를 위조!
)
Toast.makeText(this, "오프라인 모드로 실행되었습니다.", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "로컬 파일을 불러오지 못했습니다.", Toast.LENGTH_SHORT).show()
}
}
// 웹에서 호출할 수 있는 인터페이스 클래스
inner class WebAppInterface {
@JavascriptInterface
fun saveBackup(jsonData: String) {
// 웹에서 전달받은 JSON 데이터를 임시 저장
pendingBackupData = jsonData
// UI 스레드에서 파일 저장 탐색기 실행
runOnUiThread {
val fileName = "web_reader_backup_${System.currentTimeMillis()}.json"
fileSaveLauncher.launch(fileName)
}
}
}
// 볼륨 키 이벤트 가로채기 (페이지 넘김)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
webView.evaluateJavascript("window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'ArrowRight'}));", null)
return true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
webView.evaluateJavascript("window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'ArrowLeft'}));", null)
return true
}
}
return super.onKeyDown(keyCode, event)
}
override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
} else {
super.onBackPressed()
}
}
}
@@ -105,6 +105,7 @@ class PortMessage {
var base64Data: String? = null
var value : String? = null
var url : String? = null
var subTitles : List<Map<String, String>> = listOf()
}
class BookContents {
var chapterTitle : String? = null
@@ -101,6 +101,10 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
}
}
override fun usePageInfo(): Boolean {
return false
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Blog.LOGD(log = "onConfigurationChanged ${this::class.java.name} >> newConfig ${newConfig}")
@@ -181,14 +185,14 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
putBoolean(ARG_ENABLE_GESTURE, false)
}
}
// https://git.lunaticbum.kr/reader.html
fun newInstanceNovels(): TokiFragment = TokiFragment().apply {
arguments = Bundle().apply {
putString(ARG_TYPE, "book")
putInt(ARG_LAST_NUM, 468)
putString(ARG_NAME, "booktoki")
putString(ARG_DOT, "com")
putBoolean(ARG_USE_NUM_URL, true)
putString(ARG_TYPE, "web")
putInt(ARG_LAST_NUM, 26)
putString(ARG_NAME, "bookto26®")
putString(ARG_DOT, ".com")
putBoolean(ARG_USE_NUM_URL, false)
putBoolean(ARG_ENABLE_GESTURE, true)
}
}
@@ -617,10 +621,10 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
}
}
private var originalVolume: Int = -1
private val audioManager by lazy {
requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
// private var originalVolume: Int = -1
// private val audioManager by lazy {
// requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager
// }
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
saveContinuation = false
@@ -836,21 +840,21 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
override fun onResume() {
super.onResume()
originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
if (contentsType.contains("youtube")) {
// 미디어 볼륨을 0으로 설정 (FLAG_SHOW_UI를 0으로 주면 볼륨 바가 뜨지 않음)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0)
}
Blog.LOGE("RssHome 활성화: 미디어 볼륨 0 설정 (이전 볼륨: $originalVolume)")
// originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
// if (contentsType.contains("youtube")) {
// // 미디어 볼륨을 0으로 설정 (FLAG_SHOW_UI를 0으로 주면 볼륨 바가 뜨지 않음)
// audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0)
// }
//
// Blog.LOGE("RssHome 활성화: 미디어 볼륨 0 설정 (이전 볼륨: $originalVolume)")
}
override fun onPause() {
super.onPause()
if (originalVolume != -1 && contentsType.contains("youtube")) {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0)
Blog.LOGE("RssHome 비활성화: 미디어 볼륨 복구 ($originalVolume)")
}
// if (originalVolume != -1 && contentsType.contains("youtube")) {
// audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0)
// Blog.LOGE("RssHome 비활성화: 미디어 볼륨 복구 ($originalVolume)")
// }
}
fun getLastinfo() : LastInfo? {
@@ -1341,7 +1345,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
}
fun onLoadedContents(aContents: String) {
Blog.LOGE("onLoadedContents ")
Blog.LOGE("onLoadedContents $aContents")
binding.pagedLayer.let { view ->
view.post {
if (aContents.length > 10) {
@@ -1421,6 +1425,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
var currentChapter: Int = 0
fun onFindTitle(contents: String) {
Blog.LOGE("contents $contents")
binding.lunaticBrowser.binding.tvTitle.text = contents
binding.lunaticBrowser.binding.tvTitle.setOnClickListener {
val builder = AlertDialog.Builder(requireContext())
@@ -36,6 +36,7 @@ interface PagedTextViewInterface {
fun onSwipeDown(touchCount : Int)
fun onSwipeUp(touchCount : Int)
fun onLongClick()
fun usePageInfo() : Boolean
}
interface PagedTextGenerateInterface {
@@ -63,32 +64,24 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
var guideLine : Guideline? = null
var pageList = mutableListOf<CharSequence>()
var summaryText : String = ""
var text : String = ""
set(new) {
field = new
val summary = new.replace(" " ,"").replace("\n" ,"").substring(0,Math.min(30,new.length))
if (summary.equals(summaryText) && summaryText.length > 100) {
var text: String = ""
set(value) {
field = value
if (value.isNotEmpty()) {
// 액티비티가 준 한 페이지 분량의 텍스트를 곧바로 자식 텍스트뷰에 바인딩합니다.
mainTextView?.text = value
} else {
if (field.length > 0) {
post {
if (width > 0 && height > 0) {
MainScope().launch {
pageList.clear()
val contentWidth =
mainTextView!!.width - (mainTextView!!.paddingLeft + mainTextView!!.paddingRight)
val contentHeight =
mainTextView!!.height - (mainTextView!!.paddingTop + mainTextView!!.paddingBottom)
val pages = paginateAsync(cleanText(text), contentWidth, contentHeight)
pageList.addAll(pages.map { it.trim() })
Blog.LOGE("pages >>> ${pages.size}")
setPageBy(0)
}
}
}
// 만약 듀얼 페이지 모드를 지원한다면 액티비티에서 2페이지 분량을
// 각각 나눠서 주입하는 것이 좋으므로, 여기서는 단순 텍스트 셋팅만 수행합니다.
if (isDualPage()) {
// 필요 시 듀얼 페이지 처리 로직을 액티비티 스펙에 맞게 확장할 수 있습니다.
// 현재는 단일 페이지 기반으로 작동하므로 비워두거나 서브 뷰를 비웁니다.
sencondTextView?.text = ""
}
} else {
mainTextView?.text = ""
sencondTextView?.text = ""
}
summaryText = summary
}
private fun cleanText(text: String): String {
@@ -167,13 +160,18 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
}
var currentPageTextView : TextView? = null
var currentChapter : TextView? = null
fun initView(context: Context) {
inflate(context, R.layout.layout_textviewer, this)
mainTextView = findViewById(R.id.first_view)
sencondTextView = findViewById(R.id.sencond_view)
currentPageTextView = findViewById(R.id.current_page)
currentChapter = findViewById(R.id.current_chapter)
if (mPagedTextViewInterface?.usePageInfo() ?: false) {
currentPageTextView?.text = ""
} else {
currentPageTextView?.text = ""
}
hanler.removeCallbacks(touchTimeover)
setOnLongClickListener { v ->
mPagedTextViewInterface?.onLongClick()
@@ -350,7 +348,11 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
} else {
(pageList?.size ?: 0) - 1
}
currentPageTextView?.text = "${realPage + 1}/${pageList?.size ?: 0 + 1}"
if (mPagedTextViewInterface?.usePageInfo() ?: false) {
} else {
currentPageTextView?.text = "${realPage + 1}/${pageList?.size ?: 0 + 1}"
}
mainTextView?.text = pageList?.get(realPage) ?: "NONE"
// Blog.LOGE("pageList.get($realPage) ${pageList?.get(realPage)}")
@@ -407,6 +409,15 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
fun setTypeface(tf: Typeface?) {
mainTextView?.setTypeface(tf)
sencondTextView?.setTypeface(tf)
if (text.isNotEmpty()) {
invalidatePagination()
}
}
private fun invalidatePagination() {
val currentText = text
text = "" // 트리거를 위해 비움
text = currentText // 다시 할당하여 paginateAsync 실행
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@@ -0,0 +1,19 @@
package bums.lunatic.launcher.model
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
import org.mongodb.kbson.ObjectId
class QuoteItem : RealmObject {
@PrimaryKey
var _id: ObjectId = ObjectId()
var textEn: String = "" // 영문 텍스트
var textKo: String = "" // 한글 텍스트
var authorEn: String = "" // 영문 작가명
var authorKo: String = "" // 한글 작가명
var sourceApi: String = "" // "ZENQUOTES" 또는 "KOR_ADVICE"
var isTranslated: Boolean = false // AI를 통해 양방향 번역이 완료되었는지 여부
var timestamp: Long = System.currentTimeMillis()
}
@@ -0,0 +1,50 @@
package bums.lunatic.launcher.model
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
import org.mongodb.kbson.ObjectId
import java.util.UUID
data class WordItem(
val word: String,
val meaning: String,
val pron: String // 💡 발음 필드 추가!
)
data class Translation(
val lang: String,
val main: String,
val sub: String,
val pron: String,
val words: List<WordItem> = emptyList() // 💡 단어장 배열 추가!
)
data class WallContentGroup(
val topic: String,
val translations: List<Translation>
)
class ExpressionWord : RealmObject {
var word: String = ""
var meaning: String = ""
var pronunciation: String = "" // 💡 발음 필드 추가!
}
class ExpressionItem : RealmObject {
@PrimaryKey
var _id: ObjectId = ObjectId()
var topic: String = ""
var lang: String = "EN"
var mainText: String = ""
var subText: String = ""
var pronunciation: String = ""
// 💡 DB에 단어 리스트를 저장할 수 있도록 RealmList 추가
var words: RealmList<ExpressionWord> = realmListOf()
var useCount: Int = 0
var isMemorized: Boolean = false
var timestamp: Long = System.currentTimeMillis()
}
@@ -0,0 +1,417 @@
package bums.lunatic.launcher.player
import android.content.Context
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.widget.SeekBar
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import bums.lunatic.launcher.R
import bums.lunatic.launcher.home.tokiz.TouchArea
import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
import bums.lunatic.launcher.home.tokiz.view.PagedTextViewInterface
import bums.lunatic.launcher.utils.FileUtils.detectFileEncoding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.Charset
import kotlin.math.max
class DocumentViewerActivity : AppCompatActivity(), PagedTextViewInterface {
private lateinit var pagedLayout: PagedTextLayout
private lateinit var header: View
private var currentFile: File? = null
private var encoding: String = "utf-8"
private lateinit var pageIndexer: PageIndexer
private var currentPageIndex = 0
private var indexingJob: kotlinx.coroutines.Job? = null
// 2. 기존 initializeReader() 함수를 크기 변경 시에도 재사용 가능하도록 리팩토링
private fun initializeReader(targetOffset: Long = -1L) {
// 기존 작동 중인 인덱싱이 있다면 취소
indexingJob?.cancel()
indexingJob = lifecycleScope.launch {
currentFile?.let { file ->
// 인코딩은 최초 1회만 구해도 무방하므로 검증 로직 제외 가능
if (encoding.isEmpty()) encoding = detectFileEncoding(file)
val paint = pagedLayout.mainTextView?.paint ?: return@launch
val targetWidth = (pagedLayout.mainTextView!!.width * 0.8).toInt()
val targetHeight = (pagedLayout.mainTextView!!.height * 0.8).toInt()
// 0 이하의 크기 방어 (뷰가 아직 가로세로 측정이 안 되었을 때)
if (targetWidth <= 0 || targetHeight <= 0) return@launch
pageIndexer = PageIndexer(
file, encoding, paint, targetWidth, targetHeight
)
var hasRestoredPage = false
pageIndexer.buildIndex { progress ->
runOnUiThread {
try {
val currentOffsetsSize = pageIndexer.pageOffsets.size
if (!hasRestoredPage) {
if (targetOffset >= 0L) {
// [크기 재조정 케이스] 기억해둔 오프셋 위치가 확보되었는지 확인
val foundIndex = pageIndexer.pageOffsets.indexOfLast { it <= targetOffset }
// 마지막 오프셋이거나, 다음 페이지 오프셋까지 리스트에 확보되었을 때 전환
if (foundIndex >= 0 && (foundIndex < currentOffsetsSize - 1 || progress >= 100)) {
showPage(foundIndex)
hasRestoredPage = true
}
} else {
// [최초 진입 케이스] SharedPreferences 복구
val savedPageIndex = sharedPreferences.getInt(file.absolutePath, 0)
if (currentOffsetsSize > savedPageIndex) {
showPage(savedPageIndex)
hasRestoredPage = true
}
}
}
pagedLayout.currentPageTextView?.text = "${currentPageIndex + 1} / $currentOffsetsSize"
}
catch (e : Exception) {
e.printStackTrace()
}
}
}
}
}
}
// 3. 팝업 크기 변경 감지 콜백 구현
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
// 현재 읽고 있던 페이지의 실제 파일 시작 offset을 백업합니다.
val currentOffsetBackup = if (::pageIndexer.isInitialized && currentPageIndex in pageIndexer.pageOffsets.indices) {
pageIndexer.pageOffsets[currentPageIndex]
} else {
-1L
}
// 뷰가 새로운 크기로 완전히 배치(Layout)된 후 재인덱싱을 돌려야 정확한 가로/세로가 나옵니다.
pagedLayout.post {
initializeReader(targetOffset = currentOffsetBackup)
}
}
// 4. onDestroy 스케줄러 취소 안전장치 추가
override fun onDestroy() {
indexingJob?.cancel()
super.onDestroy()
try {
raf.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
// SharedPreferences 정의 (마지막 페이지 저장용)
private val sharedPreferences by lazy {
getSharedPreferences("DocumentViewerPrefs", Context.MODE_PRIVATE)
}
private val raf by lazy {
if (currentFile != null) RandomAccessFile(currentFile, "r")
else throw IllegalStateException("File is not initialized")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_document_viewer)
pagedLayout = findViewById(R.id.pagedTextLayout)
header = findViewById(R.id.layoutDocHeader)
pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
pagedLayout.setLineSpacing(20f)
pagedLayout.setLetterSpacing(0f)
val filePath = intent.getStringExtra("FILE_PATH") ?: return finish()
currentFile = File(filePath)
pagedLayout.mPagedTextViewInterface = this
pagedLayout.post {
currentFile?.let {
initializeReader()
}
}
}
private fun initializeReader() {
lifecycleScope.launch {
currentFile?.let { file ->
encoding = detectFileEncoding(file)
pageIndexer = PageIndexer(
file, encoding,
pagedLayout.mainTextView!!.paint,
(pagedLayout.mainTextView!!.width * 0.8).toInt(),
(pagedLayout.mainTextView!!.height * 0.8).toInt()
)
// 해당 파일 패스로 저장된 마지막 페이지 인덱스 가져오기 (없으면 0)
val savedPageIndex = sharedPreferences.getInt(file.absolutePath, 0)
var hasRestoredPage = false
var find10p = false
// 백그라운드에서 인덱스 생성
pageIndexer.buildIndex { progress ->
runOnUiThread {
val currentOffsetsSize = pageIndexer.pageOffsets.size
// 1. 저장된 목표 페이지 인덱스 이상으로 인덱싱이 확보되었을 때 즉시 복구
if (!hasRestoredPage && currentOffsetsSize > savedPageIndex) {
showPage(savedPageIndex)
hasRestoredPage = true
}
// 2. 저장된 페이지가 0번인데 아직 복구 안 된 경우, 기존 10% 진행 시점 방어 코드 동작
else if (!hasRestoredPage && !find10p && progress > 10) {
showPage(0)
find10p = true
}
pagedLayout.currentPageTextView?.text = "${currentPageIndex + 1} / $currentOffsetsSize"
}
}
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// 인덱서가 초기화되지 않았다면 볼륨키가 기본 동작(볼륨 조절)을 하도록 내버려 둡니다.
if (!::pageIndexer.isInitialized || pageIndexer.pageOffsets.isEmpty()) {
return super.onKeyDown(keyCode, event)
}
return when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT,KeyEvent.KEYCODE_VOLUME_UP -> {
// 볼륨 업 키 -> 이전 페이지로 이동 (기존 온스wipeRight 로직 활용)
if (currentPageIndex > 0) {
showPage(currentPageIndex - 1)
}
true // 시스템 볼륨 UI가 뜨지 않도록 이벤트를 차단(소비)합니다.
}
KeyEvent.KEYCODE_DPAD_RIGHT,KeyEvent.KEYCODE_VOLUME_DOWN -> {
// 볼륨 다운 키 -> 다음 페이지로 이동 (기존 온스wipeLeft 로직 활용)
if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
showPage(currentPageIndex + 1)
}
true // 시스템 볼륨 UI가 뜨지 않도록 이벤트를 차단(소비)합니다.
}
else -> super.onKeyDown(keyCode, event)
}
}
private fun showPage(pageIndex: Int) {
if (!::pageIndexer.isInitialized || pageIndex !in pageIndexer.pageOffsets.indices) return
currentPageIndex = pageIndex
currentFile?.let { file ->
sharedPreferences.edit().putInt(file.absolutePath, currentPageIndex).apply()
}
lifecycleScope.launch {
val pageText = withContext(Dispatchers.IO) {
val startOffset = pageIndexer.pageOffsets[pageIndex]
val endOffset = if (pageIndex + 1 < pageIndexer.pageOffsets.size) {
pageIndexer.pageOffsets[pageIndex + 1]
} else {
raf.length()
}
val pageSize = (endOffset - startOffset).toInt()
val buffer = ByteArray(pageSize)
raf.seek(startOffset)
raf.read(buffer)
String(buffer, Charset.forName(encoding))
}
// 메인 스레드에서 UI 업데이트
pagedLayout.text = pageText
pagedLayout.currentPageTextView?.text = "${currentPageIndex + 1} / ${pageIndexer.pageOffsets.size}"
pagedLayout.currentChapter?.text = pageIndexer.getChapterForPage(currentPageIndex)?.title
pagedLayout.currentChapter?.setOnClickListener {
if (!::pageIndexer.isInitialized || pageIndexer.pageOffsets.isEmpty()) return@setOnClickListener
showChapterListDialog()
}
}
}
override fun onTouch(touchArea: TouchArea) {
// if (!::pageIndexer.isInitialized || pageIndexer.pageOffsets.isEmpty()) return
// if (touchArea == TouchArea.Center) {
//// showPageSeekDialog()
// showChapterListDialog()
// }
}
private fun moveToNextChapter() {
if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) return
// 현재 페이지보다 뒤에 있는 가장 첫 번째 챕터를 찾습니다.
val nextChapter = pageIndexer.chapters.firstOrNull { it.pageIndex > currentPageIndex }
if (nextChapter != null) {
showPage(nextChapter.pageIndex)
} else {
// 더 이상 다음 챕터가 없을 때 처리 (예: 토스트 알림)
}
}
private fun showChapterListDialog() {
if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) {
Toast.makeText(this, "인덱싱된 챕터가 없습니다.", Toast.LENGTH_SHORT).show()
return
}
// 1. 현재 읽고 있는 페이지가 어떤 챕터에 속해 있는지 index를 찾습니다.
var currentChapterIndex = 0
for (i in pageIndexer.chapters.indices) {
if (pageIndexer.chapters[i].pageIndex <= currentPageIndex) {
currentChapterIndex = i
} else {
break
}
}
// 2. 챕터 제목들을 리스트뷰에 보여주기 위한 배열 변환
val chapterTitles = pageIndexer.chapters.map { it.title }.toTypedArray()
// 3. AlertDialog 생성
val builder = AlertDialog.Builder(this)
builder.setTitle("목차 (현재 위치로 이동)")
// setItems 대신 Adapter를 직접 지정하거나 리스트를 생성해야 스크롤 제어가 가능합니다.
builder.setItems(chapterTitles) { dialog, which ->
// 선택한 챕터의 페이지로 이동
val targetPage = pageIndexer.chapters[which].pageIndex
showPage(targetPage)
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
// 4. [핵심] 다이얼로그가 화면에 나타난 후, 내부 ListView를 찾아 현재 읽던 챕터 위치로 스크롤을 올립니다.
dialog.listView?.let { listView ->
listView.post {
// 현재 읽고 있는 챕터(currentChapterIndex)가 리스트의 최상단에 보이도록 스크롤 이동
listView.setSelection(currentChapterIndex)
}
}
}
/**
* 이전 챕터로 이동하는 함수
*/
private fun moveToPrevChapter() {
if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) return
// 현재 페이지보다 앞에 있는 챕터들을 역순으로 탐색하여 가장 가까운 챕터를 찾습니다.
// 단, 현재 딱 챕터 시작점에 걸려있다면 그 전 챕터로 가야 하므로 기준을 '현재 페이지 - 1'로 잡는 것이 자연스럽습니다.
val targetIndex = if (currentPageIndex > 0) currentPageIndex - 1 else 0
val prevChapter = pageIndexer.chapters.lastOrNull { it.pageIndex <= targetIndex }
if (prevChapter != null) {
showPage(prevChapter.pageIndex)
} else {
// 이미 첫 번째 챕터 앞이거나 챕터가 없을 때 처리 -> 맨 처음 페이지로
showPage(0)
}
}
private fun showPageSeekDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_page_seek, null)
val tvDialogProgress = dialogView.findViewById<TextView>(R.id.tvDialogProgress)
val seekBarPage = dialogView.findViewById<SeekBar>(R.id.seekBarPage)
val totalPages = pageIndexer.pageOffsets.size
val currentPercent = if (totalPages > 1) {
((currentPageIndex.toFloat() / (totalPages - 1)) * 100).toInt()
} else {
0
}
seekBarPage.progress = currentPercent
tvDialogProgress.text = "$currentPercent% (${currentPageIndex + 1} / $totalPages)"
var targetPageIndex = currentPageIndex
seekBarPage.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
targetPageIndex = if (totalPages > 1) {
((progress.toFloat() / 100) * (totalPages - 1)).toInt().coerceIn(0, totalPages - 1)
} else {
0
}
tvDialogProgress.text = "$progress% (${targetPageIndex + 1} / $totalPages)"
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
AlertDialog.Builder(this)
.setTitle("페이지 이동")
.setView(dialogView)
.setPositiveButton("이동") { dialog, _ ->
showPage(targetPageIndex)
dialog.dismiss()
}
.setNegativeButton("취소") { dialog, _ ->
dialog.dismiss()
}
.show()
}
override fun onSwipeLeft(count: Int) {
if (!::pageIndexer.isInitialized) return
if (count > 2) {moveToNextChapter()}
val pageSizeToMove = max((count - 1) * 10, 1)
if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
val targetPage = (currentPageIndex + pageSizeToMove).coerceAtMost(pageIndexer.pageOffsets.size - 1)
showPage(targetPage)
}
}
override fun onSwipeRight(count: Int) {
if (!::pageIndexer.isInitialized) return
val pageSizeToMove = max((count - 1) * 10, 1)
if (currentPageIndex > 0) {
val targetPage = (currentPageIndex - pageSizeToMove).coerceAtLeast(0)
showPage(targetPage)
}
}
override fun onLongClick() {
}
override fun usePageInfo(): Boolean = true
override fun onTimeoverTouch() {}
override fun onSwipeDown(count: Int) {}
override fun onSwipeUp(count: Int) {}
}
@@ -0,0 +1,112 @@
package bums.lunatic.launcher.player
import android.media.ExifInterface
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import com.bumptech.glide.Glide
import java.io.File
class ImageViewerActivity : AppCompatActivity() {
private lateinit var layoutMetaInfo: View
private val hideHandler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideMetaInfo() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_image_viewer)
val photoView = findViewById<com.github.chrisbanes.photoview.PhotoView>(R.id.photoView)
layoutMetaInfo = findViewById(R.id.layoutMetaInfo)
val btnToggleMeta = findViewById<ImageButton>(R.id.btnToggleMeta)
val imagePath = intent.getStringExtra("IMAGE_PATH") ?: return
// 1. 이미지 로드
Glide.with(this).load(File(imagePath)).into(photoView)
// 2. 메타데이터(Exif) 추출 및 표시
displayExifInfo(imagePath)
// 3. 초기 실행 시 3초간 보여주기
showMetaInfoWithDelay()
// 4. 버튼 클릭 리스너
btnToggleMeta.setOnClickListener {
if (layoutMetaInfo.visibility == View.VISIBLE) {
hideMetaInfo()
} else {
showMetaInfoWithDelay()
}
}
}
private fun getAllExifDetails(path: String): String {
val exif = androidx.exifinterface.media.ExifInterface(path)
val sb = StringBuilder()
// 1. 리플렉션을 사용하여 ExifInterface의 모든 TAG_ 필드를 가져옴
val fields = androidx.exifinterface.media.ExifInterface::class.java.fields
for (field in fields) {
if (field.name.startsWith("TAG_")) {
try {
val tagName = field.get(null) as String
val value = exif.getAttribute(tagName)
// 값이 있는 태그만 추가
if (!value.isNullOrBlank()) {
// 태그명에서 "TAG_" 접두어 제거하고 읽기 쉽게 변환 (예: TAG_MODEL -> Model)
val friendlyName = field.name.removePrefix("TAG_").replace("_", " ")
sb.append("**$friendlyName**: $value\n")
}
} catch (e: Exception) {
// 특정 태그 접근 실패 시 무시
}
}
}
// 2. 위치 정보는 별도 계산 (문자열보다 좌표값이 정확함)
val latLong = FloatArray(2)
if (exif.getLatLong(latLong)) {
sb.append("\n📍 **위치(GPS)**: ${latLong[0]}, ${latLong[1]}")
}
return if (sb.isEmpty()) "메타데이터 정보가 없습니다." else sb.toString()
}
private fun displayExifInfo(path: String) {
val exif = androidx.exifinterface.media.ExifInterface(path)
val info = StringBuilder().apply {
appendLine("모델: ${exif.getAttribute(ExifInterface.TAG_MODEL) ?: "알 수 없음"}\n")
appendLine("날짜: ${exif.getAttribute(ExifInterface.TAG_DATETIME) ?: "-"}\n")
appendLine("해상도: ${exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)}x${exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)}\n")
appendLine("조리개: f/${exif.getAttribute(ExifInterface.TAG_F_NUMBER) ?: "-"}")
appendLine(getAllExifDetails(path))
}.toString()
findViewById<TextView>(R.id.tvMetaTitle).text = File(path).name
findViewById<TextView>(R.id.tvMetaData).text = info
}
private fun showMetaInfoWithDelay() {
hideHandler.removeCallbacks(hideRunnable) // 기존 예약된 숨김 제거
layoutMetaInfo.visibility = View.VISIBLE
layoutMetaInfo.animate().alpha(1f).setDuration(300).start()
// 3초 후 숨김 예약
hideHandler.postDelayed(hideRunnable, 5000)
}
private fun hideMetaInfo() {
layoutMetaInfo.animate().alpha(0f).setDuration(300).withEndAction {
layoutMetaInfo.visibility = View.GONE
}.start()
}
}
@@ -0,0 +1,111 @@
package bums.lunatic.launcher.player
import android.view.Surface
class NativePlayer {
private var nativeHandle: Long = 0
private var subtitleCallback: ((String) -> Unit)? = null
private var videoSizeCallback: ((Int, Int) -> Unit)? = null
// 💡 JNI로부터 받을 콜백 리스너 설정
var onPreparedListener: (() -> Unit)? = null
var onErrorListener: ((Int, String) -> Unit)? = null
fun initialize(): Boolean {
nativeHandle = nativeInit()
return nativeHandle != 0L
}
fun setDataSource(videoFd: Int, subFd: Int) = nativeSetDataSource(nativeHandle, videoFd, subFd)
fun prepareAsync() { if (nativeHandle != 0L) nativePrepareAsync(nativeHandle) }
fun play(surface: Surface) = nativePlay(nativeHandle, surface)
fun pause() { if (nativeHandle != 0L) nativePause(nativeHandle) }
fun stop() = nativeStop(nativeHandle)
fun seekBy(sec: Double) = nativeSeekBy(nativeHandle, sec)
fun setSpeed(speed: Float) = nativeSetSpeed(nativeHandle, speed)
// 외부 함수 호출 정의
fun getDuration(): Double {
return if (nativeHandle != 0L) nativeGetDuration(nativeHandle) else 0.0
}
fun seekTo(sec: Double) {
if (nativeHandle != 0L) nativeSeekTo(nativeHandle, sec)
}
// 하단에 external fun 추가
private external fun nativeGetDuration(h: Long): Double
private external fun nativeSeekTo(h: Long, s: Double)
@Suppress("unused")
private fun onSubtitleTextDecoded(text: String) {
subtitleCallback?.invoke(text)
}
@Suppress("unused")
private fun onVideoSizeChanged(w: Int, h: Int) {
videoSizeCallback?.invoke(w, h)
}
// 💡 C++에서 준비 완료 시 호출
@Suppress("unused")
private fun onNativePrepared() {
onPreparedListener?.invoke()
}
// 💡 C++에서 오류 발생 시 호출
@Suppress("unused")
private fun onNativeError(errorCode: Int, errorMessage: String) {
onErrorListener?.invoke(errorCode, errorMessage)
}
fun setSubtitleCallback(cb: (String) -> Unit) { subtitleCallback = cb }
fun setVideoSizeCallback(cb: (Int, Int) -> Unit) { videoSizeCallback = cb }
fun destroy() {
if (nativeHandle != 0L) {
nativeDestroy(nativeHandle)
nativeHandle = 0L
}
}
data class SubtitleTrack(
val id: String,
val name: String,
val isExternal: Boolean
)
fun getInternalSubtitleTracks(): List<SubtitleTrack> {
if (nativeHandle == 0L) return emptyList()
val rawStr = nativeGetSubtitleTracks(nativeHandle) ?: return emptyList()
return rawStr.split(",").filter { it.isNotBlank() }.mapNotNull {
val parts = it.split(":")
if (parts.size >= 2) SubtitleTrack(parts[0], parts[1], false) else null
}
}
fun setInternalSubtitleTrack(streamIndex: Int) {
if (nativeHandle != 0L) nativeSetSubtitleTrack(nativeHandle, streamIndex)
}
fun getCurrentPosition(): Double {
return if (nativeHandle != 0L) nativeGetCurrentPosition(nativeHandle) else 0.0
}
private external fun nativeInit(): Long
private external fun nativeSetDataSource(h: Long, videoFd: Int, subFd: Int)
private external fun nativePrepareAsync(h: Long)
private external fun nativePlay(h: Long, s: Surface)
private external fun nativePause(h: Long)
private external fun nativeStop(h: Long)
private external fun nativeDestroy(h: Long)
private external fun nativeSeekBy(h: Long, s: Double)
private external fun nativeSetSpeed(h: Long, sp: Float)
private external fun nativeGetCurrentPosition(h: Long): Double
private external fun nativeGetSubtitleTracks(h: Long): String
private external fun nativeSetSubtitleTrack(h: Long, index: Int)
companion object {
init { System.loadLibrary("native_renderer") }
}
}
@@ -0,0 +1,150 @@
package bums.lunatic.launcher.player
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.Charset
import java.util.regex.Pattern
data class Chapter(
val title: String,
val pageIndex: Int
)
class PageIndexer(
private val file: File,
private val encoding: String,
private val paint: TextPaint,
private val viewWidth: Int,
private val viewHeight: Int
) {
val pageOffsets = ArrayList<Long>()
val chapters = ArrayList<Chapter>()
private val charset = Charset.forName(encoding)
fun getChapterForPage(currentPageIndex: Int): Chapter? {
// 현재 페이지보다 작거나 같은 시작 페이지를 가진 챕터 중, 가장 마지막 챕터를 찾습니다.
return chapters.lastOrNull { it.pageIndex <= currentPageIndex }
}
private val chapterPattern = Pattern.compile(
"(?:제\\s*)?\\d+\\s*[화|장|막|절|편]"
)
suspend fun buildIndex(onProgress: (Int) -> Unit) = withContext(Dispatchers.Default) {
val raf = RandomAccessFile(file, "r")
val fileLength = raf.length()
var currentOffset = 0L
var lastProgress = -1
pageOffsets.add(currentOffset)
val bufferSize = 40000
val buffer = ByteArray(bufferSize)
while (currentOffset < fileLength) {
raf.seek(currentOffset)
val bytesRead = raf.read(buffer)
if (bytesRead == -1) break
val chunkText = String(buffer, 0, bytesRead, charset)
if (chunkText.isEmpty()) break
val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(chunkText, 0, chunkText.length, paint, viewWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.build()
} else {
@Suppress("DEPRECATION")
StaticLayout(chunkText, paint, viewWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 1.0f, false)
}
val totalLines = layout.lineCount
var startLine = 0
var chunkConsumedOffset = 0
while (startLine < totalLines) {
val pageTopY = layout.getLineTop(startLine)
val targetBottomY = pageTopY + viewHeight
var endLine = layout.getLineForVertical(targetBottomY)
if (layout.getLineBottom(endLine) > targetBottomY) {
endLine--
}
if (endLine < startLine) endLine = startLine
val endCharOffset = layout.getLineEnd(endLine)
var pageText = chunkText.substring(chunkConsumedOffset, endCharOffset)
// -------------------------------------------------------------------------
// [수정 구간] 챕터 탐지 및 강제 페이지 분할 로직
// -------------------------------------------------------------------------
val matcher = chapterPattern.matcher(pageText)
if (matcher.find()) {
val matchStart = matcher.start()
// 챕터 제목 줄의 시작 위치 계산
val lineStart = pageText.lastIndexOf('\n', matchStart).let { if (it == -1) 0 else it + 1 }
val lineEnd = pageText.indexOf('\n', matchStart).let { if (it == -1) pageText.length else it }
val chapterTitle = pageText.substring(lineStart, lineEnd).trim()
// 글자수 제한 체크 (40자 미만 정상 챕터인 경우)
if (chapterTitle.isNotEmpty() && chapterTitle.length < 40) {
// 만약 챕터가 페이지의 맨 처음(lineStart == 0)이 아니라면,
// 즉, 챕터 앞에 이전 장의 본문 내용이 포함되어 있다면 강제로 쪼갭니다.
if (lineStart > 0) {
// 1. 챕터 직전 내용까지만 현재 페이지 텍스트로 인정합니다.
val previousSectionText = pageText.substring(0, lineStart)
val previousSectionBytes = previousSectionText.toByteArray(charset).size
// 2. 현재 오프셋에 이전 내용 크기를 더해 "새 페이지(챕터 시작점)" 오프셋을 구합니다.
currentOffset += previousSectionBytes
// 3. 페이지 목록에 새 오프셋(챕터의 시작점)을 즉시 추가합니다.
pageOffsets.add(currentOffset)
// 4. 다음 루프를 위해 chunk 내 소비된 문자 인덱스를 업데이트합니다.
chunkConsumedOffset += lineStart
// 5. StaticLayout 상에서 이 챕터 줄이 속한 실제 Line을 찾아서 루프의 startLine을 갱신합니다.
// layout.getLineForOffset은 전체 chunkText 기준이므로 chunkConsumedOffset을 대입합니다.
startLine = layout.getLineForOffset(chunkConsumedOffset)
// 중요: 이번 루프는 여기서 끊고, 쪼개진 새 페이지 기준으로 다음 루프에서 처리하도록 넘깁니다.
continue
} else {
// 이미 페이지의 맨 첫 줄이 챕터인 경우 바로 리스트에 등록합니다.
val currentPageIndex = pageOffsets.size - 1
if (chapters.isEmpty() || chapters.last().pageIndex != currentPageIndex) {
chapters.add(Chapter(chapterTitle, currentPageIndex))
}
}
}
}
// -------------------------------------------------------------------------
// 챕터 분할이 일어나지 않은 일반적인 페이지 처리 flow
val pageBytesSize = pageText.toByteArray(charset).size
currentOffset += pageBytesSize
chunkConsumedOffset = endCharOffset
if (currentOffset < fileLength) {
pageOffsets.add(currentOffset)
}
val progress = ((currentOffset * 100) / fileLength).toInt()
if (progress != lastProgress) {
onProgress(progress)
lastProgress = progress
}
startLine = endLine + 1
}
}
raf.close()
}
}
@@ -0,0 +1,941 @@
package bums.lunatic.launcher.player
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.graphics.SurfaceTexture
import android.os.Build
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.util.Log
import android.view.*
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContentProviderCompat.requireContext
import bums.lunatic.launcher.R
import bums.lunatic.launcher.home.GeckoWeb
import bums.lunatic.launcher.player.NativePlayer.SubtitleTrack
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.TranslatorOptions
import kotlinx.coroutines.*
import java.io.File
import java.net.URLEncoder
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
data class SubtitleBlock(
val startSec: Double,
val endSec: Double,
val text: String,
var translatedText: String? = null // 💡 번역본이 저장될 공간
)
private lateinit var videoTextureView: TextureView
private lateinit var geckoWeb: GeckoWeb
private lateinit var subtitleView: TextView
private lateinit var btnRotate: ImageButton
private lateinit var btnHideVideo: ImageButton
private var nativePlayer: NativePlayer? = null
private var videoPath: String = ""
private var subtitlePath: String = ""
private var isPlaying = true
private var isVideoHidden = false
private var leftLongPressJob: Job? = null
private var videoWidth: Int = 0
private var videoHeight: Int = 0
private var externalSubtitles = listOf<SubtitleBlock>()
private var subtitleSyncJob: Job? = null
private val allSubtitleTracks = mutableListOf<SubtitleTrack>()
private var lastCheckedPos = -1.0
private var lastCheckedTime = 0L
private var isRecovering = false
private fun rebootEngine(resumePos: Double) {
isRecovering = true
Toast.makeText(this, "재생 환경을 복구하는 중입니다...", Toast.LENGTH_SHORT).show()
// 1. 기존 엔진 중지
nativePlayer?.stop()
// 2. 다시 준비 (기존 경로 및 surface 활용)
// onPreparedListener에서 resumePos로 다시 seekTo 하도록 설정
nativePlayer?.onPreparedListener = {
nativePlayer?.seekTo(resumePos)
nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
isRecovering = false
lastCheckedTime = System.currentTimeMillis() // 타임스탬프 갱신
}
// 이전에 사용했던 FD를 다시 넘기거나 경로를 통해 다시 Open
prepareEngine()
}
private fun prepareEngine() {
val videoFile = File(videoPath)
if (videoFile.exists()) {
val videoPfd = ParcelFileDescriptor.open(videoFile, ParcelFileDescriptor.MODE_READ_ONLY)
val videoFd = videoPfd.detachFd()
var subFd = -1
if (subtitlePath.isNotEmpty()) {
val subFile = File(subtitlePath)
if (subFile.exists()) {
val subPfd = ParcelFileDescriptor.open(subFile, ParcelFileDescriptor.MODE_READ_ONLY)
subFd = subPfd.detachFd()
}
}
// C++로 DataSource를 넘기고 비동기 Prepare 명령!
nativePlayer?.setDataSource(videoFd, subFd)
nativePlayer?.prepareAsync()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
super.onCreate(savedInstanceState)
videoPath = intent.getStringExtra("VIDEO_PATH") ?: ""
if (videoPath.isEmpty()) { finish(); return }
subtitlePath = findSubtitleFile(videoPath)
if (subtitlePath.isNotEmpty()) {
externalSubtitles = parseSrt(File(subtitlePath))
detectLanguageAndTranslate()
}
setupUI()
nativePlayer = NativePlayer().apply {
initialize()
setVideoSizeCallback { width, height ->
videoWidth = width
videoHeight = height
adjustVideoAspectRatio(width, height)
}
setSubtitleCallback { text ->
runOnUiThread {
subtitleView.text = cleanSubtitleText(text)
subtitleView.visibility = if (text.isEmpty()) View.INVISIBLE else View.VISIBLE
}
}
// 💡 C++에서 준비 완료 시 호출
onPreparedListener = {
runOnUiThread {
loadAvailableSubtitles()
if (allSubtitleTracks.size > 1) {
showSubtitleSelectionDialog()
} else {
if (videoPath.contains("Youtube")) {
play()
} else {
showSubtitleSearchConfirmDialog()
}
}
}
}
// 💡 C++에서 오류 발생 시 호출
onErrorListener = { errorCode, errorMessage ->
runOnUiThread {
Toast.makeText(this@PlayerActivity, "오류 [$errorCode]: $errorMessage", Toast.LENGTH_LONG).show()
finish()
}
}
}
setupGestures()
}
private fun showSubtitleSearchConfirmDialog() {
AlertDialog.Builder(this)
.setTitle("자막 없음")
.setMessage("재생할 자막이 없습니다. 온라인에서 자막을 검색해볼까요?")
.setPositiveButton("검색") { _, _ ->
// 파일명을 쿼리로 전달하여 검색 실행
val videoFileName = File(videoPath).nameWithoutExtension
searchSubtitles(videoFileName)
}
.setNegativeButton("그냥 재생") { _, _ ->
play() // 자막 없이 재생 시작
}
.setCancelable(false)
.show()
}
private lateinit var seekBar: android.widget.SeekBar
private lateinit var tvTime: TextView
private var uiUpdateJob: Job? = null
private var isUserSeeking = false
private fun setupSyncControls(root: FrameLayout) {
val topControlLayout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
setPadding(0, 20, 0, 0)
}
// [ -0.5s ] 버튼
val btnPrev = TextView(this).apply {
text = " -0.5s "
setTextColor(Color.YELLOW)
setPadding(30, 10, 30, 10)
setBackgroundColor(Color.parseColor("#66000000"))
setOnClickListener {
adjustSubtitleSync(500)
}
}
// [ SYNC RESET ] 버튼
val btnReset = TextView(this).apply {
text = " SYNC RESET "
setTextColor(Color.WHITE)
setPadding(40, 10, 40, 10)
setMargins(20, 0, 20, 0)
setBackgroundColor(Color.parseColor("#AA000000"))
setOnClickListener {
syncSubtitleToCurrentTime()
}
}
// [ +0.5s ] 버튼
val btnNext = TextView(this).apply {
text = " +0.5s "
setTextColor(Color.YELLOW)
setPadding(30, 10, 30, 10)
setBackgroundColor(Color.parseColor("#66000000"))
setOnClickListener {
adjustSubtitleSync(500)
}
}
topControlLayout.addView(btnPrev)
topControlLayout.addView(btnReset)
topControlLayout.addView(btnNext)
root.addView(topControlLayout, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.CENTER_HORIZONTAL).apply {
topMargin = 100 // 노치나 상태바 아래로 배치
})
}
// 헬퍼 함수: Margin 적용용
fun View.setMargins(l: Int, t: Int, r: Int, b: Int) {
if (layoutParams is ViewGroup.MarginLayoutParams) {
val p = layoutParams as ViewGroup.MarginLayoutParams
p.setMargins(l, t, r, b)
requestLayout()
}
}
private fun syncSubtitleToCurrentTime() {
if (externalSubtitles.isEmpty()) return
val currentVideoSec = nativePlayer?.getCurrentPosition() ?: 0.0
val firstSubtitleStartSec = externalSubtitles[0].startSec
val neededDelaySec = firstSubtitleStartSec - currentVideoSec
subtitleDelayMs = (neededDelaySec * 1000).toLong()
currentSubtitleIndex = 0
lastSubTitle = "" // 💡 추가: 즉시 갱신을 위해 비움
val sign = if (subtitleDelayMs >= 0) "+" else ""
Toast.makeText(this, "자막 기준점 동기화 완료: $sign${String.format("%.1f", -neededDelaySec)}초 보정", Toast.LENGTH_SHORT).show()
}
private fun setupUI() {
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
videoTextureView = TextureView(this).apply { surfaceTextureListener = this@PlayerActivity }
geckoWeb = GeckoWeb(this).apply {
visibility = View.GONE
}
subtitleView = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 22f
setShadowLayer(8f, 0f, 0f, Color.BLACK)
gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM
setPadding(40, 0, 40, 180)
}
val gestureLayer = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
weightSum = 3f
}
val leftZone = View(this).apply { id = View.generateViewId() }
val centerZone = View(this).apply { id = View.generateViewId() }
val rightZone = View(this).apply { id = View.generateViewId() }
gestureLayer.addView(leftZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
gestureLayer.addView(centerZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
gestureLayer.addView(rightZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
btnRotate = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_rotate)
setBackgroundColor(Color.TRANSPARENT)
setOnClickListener { toggleOrientation() }
}
btnHideVideo = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_close_clear_cancel)
setBackgroundColor(Color.TRANSPARENT)
setOnClickListener { toggleVideoVisibility() }
}
root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
root.addView(subtitleView)
root.addView(gestureLayer)
root.addView(btnRotate, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.START).apply { setMargins(30,0,0,30) })
root.addView(btnHideVideo, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.END).apply { setMargins(0,0,30,30) })
val bottomControlLayout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setBackgroundColor(Color.parseColor("#99000000")) // 반투명 검은색 배경
setPadding(40, 20, 40, 20)
}
tvTime = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 16f
text = "00:00 / 00:00"
}
seekBar = android.widget.SeekBar(this).apply {
layoutParams = android.widget.LinearLayout.LayoutParams(0, -2, 1f).apply {
setMargins(30, 0, 30, 0)
}
setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(bar: android.widget.SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
val duration = nativePlayer?.getDuration() ?: 0.0
tvTime.text = "${formatTime(progress.toDouble())} / ${formatTime(duration)}"
}
}
override fun onStartTrackingTouch(bar: android.widget.SeekBar?) {
isUserSeeking = true // 사용자가 잡고 있을 때는 자동 업데이트 중지
}
override fun onStopTrackingTouch(bar: android.widget.SeekBar?) {
isUserSeeking = false
val targetSec = bar?.progress?.toDouble() ?: 0.0
nativePlayer?.seekTo(targetSec) // 원하는 위치로 이동 요청
}
})
}
bottomControlLayout.addView(tvTime)
bottomControlLayout.addView(seekBar)
root.addView(bottomControlLayout, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM))
root.addView(geckoWeb, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
setContentView(root)
setupSyncControls(root)
hideSystemUI()
}
private fun formatTime(seconds: Double): String {
val totalSec = seconds.toInt()
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) String.format("%02d:%02d:%02d", h, m, s) else String.format("%02d:%02d", m, s)
}
// UI 갱신 루프 시작 함수 추가
private fun startUIUpdateLoop() {
uiUpdateJob?.cancel()
uiUpdateJob = CoroutineScope(Dispatchers.Main).launch {
while (isActive) {
if (isPlaying && !isUserSeeking) {
val currentPos = nativePlayer?.getCurrentPosition() ?: 0.0
val duration = nativePlayer?.getDuration() ?: 0.0
if (duration > 0) {
seekBar.max = duration.toInt()
seekBar.progress = currentPos.toInt()
tvTime.text = "${formatTime(currentPos)} / ${formatTime(duration)}"
}
}
val currentPos = nativePlayer?.getCurrentPosition() ?: 0.0
val now = System.currentTimeMillis()
Log.e("PlayerWatchdog", "isPlaying $isPlaying isUserSeeking $isUserSeeking isRecovering $isRecovering" )
if (isPlaying && !isUserSeeking && !isRecovering) {
// 재생 위치가 변했는지 확인
if (currentPos != lastCheckedPos) {
lastCheckedPos = currentPos
lastCheckedTime = now
} else {
// 위치가 그대로라면, 얼마나 오래 멈춰있었는지 확인 (3초 이상)
if (now - lastCheckedTime > 3000) {
Log.e("PlayerWatchdog", "영상이 멈춘 것으로 판단됨! 복구 시작...")
rebootEngine(currentPos)
} else {
}
}
}
delay(500) // 0.5초마다 UI 갱신
}
}
}
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
prepareEngine()
}
private fun loadAvailableSubtitles() {
allSubtitleTracks.clear()
allSubtitleTracks.add(SubtitleTrack("-1", "자막 끄기", false))
val internalTracks = nativePlayer?.getInternalSubtitleTracks() ?: emptyList()
allSubtitleTracks.addAll(internalTracks)
if (externalSubtitles.isNotEmpty()) {
allSubtitleTracks.add(SubtitleTrack("EXTERNAL", "외부 자막 (SRT)", true))
}
}
fun play() {
startUIUpdateLoop()
nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
}
private fun selectSubtitleTrack(track: SubtitleTrack) {
if (track.isExternal) {
nativePlayer?.setInternalSubtitleTrack(-1)
startSubtitleSyncLoop()
} else {
subtitleSyncJob?.cancel()
subtitleView.visibility = View.INVISIBLE
nativePlayer?.setInternalSubtitleTrack(track.id.toInt())
}
}
private fun setupGestures() {
val gestureLayer = (videoTextureView.parent as FrameLayout).getChildAt(2) as android.widget.LinearLayout
val left = gestureLayer.getChildAt(0)
val center = gestureLayer.getChildAt(1)
val right = gestureLayer.getChildAt(2)
val centerDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
isPlaying = !isPlaying
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
else nativePlayer?.pause()
return true
}
// 좌우 스크롤로 자막 싱크 조절
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
if (Math.abs(distanceX) > Math.abs(distanceY)) {
if (distanceX > 0) adjustSubtitleSync(-500) // 왼쪽으로 밀면 자막을 빠르게
else adjustSubtitleSync(500) // 오른쪽으로 밀면 자막을 느리게
return true
}
return false
}
})
center.setOnTouchListener { _, event ->
centerDetector.onTouchEvent(event)
true
}
val rightDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent) { nativePlayer?.setSpeed(4.0f) }
override fun onSingleTapUp(e: MotionEvent): Boolean {
nativePlayer?.seekBy(40.0)
return true
}
})
right.setOnTouchListener { _, event ->
rightDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
nativePlayer?.setSpeed(1.0f)
}
true
}
val leftDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent) {
leftLongPressJob = CoroutineScope(Dispatchers.Main).launch {
while (isActive) {
nativePlayer?.seekBy(-20.0)
delay(500)
}
}
}
override fun onSingleTapUp(e: MotionEvent): Boolean {
nativePlayer?.seekBy(-10.0)
return true
}
})
left.setOnTouchListener { _, event ->
leftDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
leftLongPressJob?.cancel()
}
true
}
}
private var subtitleDelayMs: Long = 0L
private fun adjustSubtitleSync(deltaMs: Long) {
subtitleDelayMs += deltaMs
// 💡 핵심: 기존 자막 상태를 초기화하여 루프가 즉시 새 자막을 그리도록 유도
lastSubTitle = ""
val seconds = subtitleDelayMs / 1000.0
val sign = if (subtitleDelayMs >= 0) "+" else ""
Toast.makeText(this, "자막 싱크: $sign${String.format("%.1f", seconds)}", Toast.LENGTH_SHORT).show()
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
hideSystemUI()
if (videoWidth > 0 && videoHeight > 0) adjustVideoAspectRatio(videoWidth, videoHeight)
}
private fun convertSubtitlesToSrt(subtitles: List<SubtitleBlock>): String {
val sb = StringBuilder()
subtitles.forEachIndexed { index, block ->
sb.append("${index + 1}\n") // 자막 번호
sb.append("${formatSrtTime(block.startSec)} --> ${formatSrtTime(block.endSec)}\n")
// 번역본이 있다면 번역본을, 없다면 원본을 저장 (혹은 둘 다 병기)
val textToSave = if (!block.translatedText.isNullOrEmpty()) {
"${block.translatedText}\n${block.text}" // 번역본 + 원본 병기
} else {
block.text
}
sb.append("$textToSave\n\n")
}
return sb.toString()
}
// 00:00:00,000 포맷으로 변환
private fun formatSrtTime(seconds: Double): String {
val h = (seconds / 3600).toInt()
val m = ((seconds % 3600) / 60).toInt()
val s = (seconds % 60).toInt()
val ms = ((seconds - seconds.toInt()) * 1000).toInt()
return String.format("%02d:%02d:%02d,%03d", h, m, s, ms)
}
// 💡 화면 채움 (Fill/Crop)
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
runOnUiThread {
// 1. 현재 기기의 실제 가용 화면 크기를 가져옵니다.
val displayMetrics = resources.displayMetrics
val screenW = displayMetrics.widthPixels
val screenH = displayMetrics.heightPixels
// 2. 종횡비를 계산합니다.
val videoRatio = videoW.toFloat() / videoH.toFloat()
val screenRatio = screenW.toFloat() / screenH.toFloat()
val lp = videoTextureView.layoutParams as FrameLayout.LayoutParams
if (videoRatio > screenRatio) {
// 💡 영상이 화면보다 더 가로로 긴 경우 (가로를 꽉 채우고 세로는 비율대로 축소)
lp.width = screenW
lp.height = (screenW / videoRatio).toInt()
} else {
// 💡 영상이 화면보다 더 세로로 긴 경우 (세로를 꽉 채우고 가로는 비율대로 축소)
lp.width = (screenH * videoRatio).toInt()
lp.height = screenH
}
// 3. 뷰를 화면 정중앙에 배치합니다.
lp.gravity = Gravity.CENTER
videoTextureView.layoutParams = lp
Log.d("Player", "Video Resized: ${lp.width}x${lp.height} for Screen: ${screenW}x${screenH}")
}
}
private fun toggleOrientation() {
requestedOrientation = if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
private fun toggleVideoVisibility() {
isVideoHidden = !isVideoHidden
videoTextureView.visibility = if (isVideoHidden) View.INVISIBLE else View.VISIBLE
}
private fun findSubtitleFile(videoPath: String): String {
val file = File(videoPath)
val name = file.nameWithoutExtension
// 💡 번역된 파일을 최우선으로 탐색
val extensions = listOf("translated.srt", "srt", "ass", "smi")
for (ext in extensions) {
// ext가 "translated.srt"인 경우 name_translated.srt가 됨
val subName = if (ext.contains(".")) "${name}_$ext" else "$name.$ext"
val sub = File(file.parent, subName)
if (sub.exists()) return sub.absolutePath
}
return ""
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent) // 새로운 인텐트로 교체
// 💡 새로운 비디오 경로를 가져와서 재생 엔진 재설정
videoPath = intent?.getStringExtra("VIDEO_PATH") ?: ""
if (videoPath.isNotEmpty()) {
nativePlayer?.stop()
prepareEngine()
}
}
fun showDownSubtitleSelectionDialog(subList: List<Map<String, String>>) {
val items = subList.map { item ->
" [${item["lang"]}] ${item["title"]} (${item["size"]})"
}.toTypedArray()
Blog.LOGE("items >> ${items.size}")
AlertDialog.Builder(this)
.setTitle("자막 선택 (원본 파일명: ${subList[0]["originalFileName"]})")
.setItems(items) { _, which ->
val selected = subList[which]
val detailUrl = selected["downloadUrl"]
if (detailUrl != null) {
// 💡 상세 페이지로 이동하도록 GeckoWeb에 명령
geckoWeb.sendJsonMsg("GO_TO_SUBTITLE_DETAIL", "url" to detailUrl)
}
}
.setNegativeButton("취소", null)
.show()
}
/**
* 지정된 폴더에 자막을 다운로드하고 플레이어에 적용합니다.
* @param url 자막 다운로드 주소
* @param fileName 저장할 파일명 (확장자 제외)
* @param targetDir 저장될 부모 폴더 (: 영상이 들어있는 폴더)
*/
fun downloadSubtitle(url: String) {
val videoFile = File(videoPath)
val targetDir = File(videoPath).parentFile
val newSubFile = File(targetDir, "${videoFile.nameWithoutExtension}.srt")
CoroutineScope(Dispatchers.IO).launch {
try {
val request = okhttp3.Request.Builder()
.url(url)
// 필요 시 Referer 추가 (GeckoWeb에서 넘겨받은 값 활용 가능)
.build()
val response = okhttp3.OkHttpClient().newCall(request).execute()
if (response.isSuccessful) {
response.body?.byteStream()?.use { input ->
newSubFile.outputStream().use { output ->
input.copyTo(output)
}
}
withContext(Dispatchers.Main) {
geckoWeb.visibility = View.GONE
Toast.makeText(this@PlayerActivity, "자막 저장 완료: ${newSubFile.name}", Toast.LENGTH_SHORT).show()
// 1. 플레이어의 자막 경로 업데이트
subtitlePath = newSubFile.absolutePath
// 2. 앞서 만든 스마트 인코딩 및 번역 로직 트리거
// 이 안에서 인코딩 복구 -> 언어 감지 -> 번역 순으로 진행됩니다.
detectLanguageAndTranslate()
// 3. 자막 리스트 갱신 및 표시
loadAvailableSubtitles()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@PlayerActivity, "자막 다운로드 실패: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun searchSubtitles(query: String) {
val searchUrl = "https://www.subtitlecat.com/index.php?search=${URLEncoder.encode(query, "UTF-8")}"
geckoWeb.loadUrl(searchUrl)
geckoWeb.visibility = View.VISIBLE
}
private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "")
var lastSubTitle : String = ""
// 💡 클래스 상단에 인덱스 기억용 변수 하나만 추가해 주세요.
private var currentSubtitleIndex = 0
private fun startSubtitleSyncLoop() {
subtitleSyncJob?.cancel()
// 💡 자막이 아예 없다면 작업을 시작조차 하지 않음
if (externalSubtitles.isEmpty()) {
subtitleView.visibility = View.INVISIBLE
return
}
// 💡 Dispatchers.Main 대신 Default에서 연산하고 UI만 Main에서 갱신하는 것이 버퍼링 방지에 좋습니다.
subtitleSyncJob = CoroutineScope(Dispatchers.Main).launch {
while (isActive) {
if (isPlaying) {
val currentSec = nativePlayer?.getCurrentPosition() ?: 0.0
// 💡 최적화된 인덱스 탐색 함수 호출
val currentSub = findSubtitleIndexed(currentSec)
if (currentSub != null) {
val displayText = if (currentSub.translatedText != null) {
"${currentSub.translatedText}\n${cleanSubtitleText(currentSub.text)}"
} else {
cleanSubtitleText(currentSub.text)
}
if (displayText != lastSubTitle) {
lastSubTitle = displayText
subtitleView.text = displayText
subtitleView.visibility = View.VISIBLE
}
} else {
subtitleView.visibility = View.INVISIBLE
}
}
delay(200)
}
}
}
/**
* 💡 인덱스 기반 탐색: 이전 위치부터 찾기 때문에 CPU 부하가 거의 없습니다.
*/
private fun findSubtitleIndexed(currentSec: Double): SubtitleBlock? {
// 💡 싱크 오프셋 적용 (초 단위로 변환하여 더함)
val adjustedSec = currentSec - (subtitleDelayMs / 1000.0)
if (currentSubtitleIndex >= externalSubtitles.size ||
externalSubtitles[currentSubtitleIndex].startSec > adjustedSec) {
currentSubtitleIndex = 0
}
for (i in currentSubtitleIndex until externalSubtitles.size) {
val item = externalSubtitles[i]
if (adjustedSec in item.startSec..item.endSec) {
currentSubtitleIndex = i
return item
}
if (item.startSec > adjustedSec) break
}
return null
}
private fun parseSrt(file: File): List<SubtitleBlock> {
val result = mutableListOf<SubtitleBlock>()
if (!file.exists()) return result
try {
// 💡 기존의 file.readText() 대신 우리가 만든 스마트 함수를 사용합니다.
val content = readTextWithEncoding(file)
// --- 아래는 기존과 완전히 동일 ---
val blocks = content.split("\n\n", "\r\n\r\n")
for (block in blocks) {
val lines = block.lines().filter { it.isNotBlank() }
if (lines.size >= 3) {
val timeLine = lines[1]
val text = lines.drop(2).joinToString("\n")
val times = timeLine.split(" --> ")
if (times.size == 2) {
result.add(SubtitleBlock(parseSrtTime(times[0]), parseSrtTime(times[1]), text))
}
}
}
} catch (e: Exception) {
Log.e("PlayerActivity", "Subtitle parsing error", e)
}
result.sortBy { it.startSec }
return result
}
private fun parseSrtTime(timeStr: String): Double {
val cleanStr = timeStr.trim().replace(",", ".")
val parts = cleanStr.split(":")
if (parts.size == 3) {
val h = parts[0].toDoubleOrNull() ?: 0.0
val m = parts[1].toDoubleOrNull() ?: 0.0
val s = parts[2].toDoubleOrNull() ?: 0.0
return (h * 3600) + (m * 60) + s
}
return 0.0
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false)
window.insetsController?.let {
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {}
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { nativePlayer?.stop(); return true }
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
private fun detectLanguageAndTranslate() {
if (externalSubtitles.isEmpty()) return
// 정확도를 위해 자막 앞부분의 텍스트를 적당히 뭉쳐서 샘플로 만듭니다.
val sampleText = externalSubtitles.take(10).joinToString(" ") { it.text }
val languageIdentifier = LanguageIdentification.getClient()
languageIdentifier.identifyLanguage(sampleText)
.addOnSuccessListener { languageCode ->
if (languageCode == "und") {
Blog.LOGE("언어 감지 실패 (알 수 없는 언어)")
} else {
Blog.LOGE("💡 감지된 언어 코드: $languageCode")
// 만약 이미 한국어(ko)라면 번역할 필요가 없으므로 종료
if (languageCode == "ko") return@addOnSuccessListener
// 감지된 언어 코드를 번역기가 이해할 수 있는 코드로 변환
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
if (sourceLang != null) {
startTranslationProcess(sourceLang)
} else {
Blog.LOGE("번역을 지원하지 않는 언어입니다: $languageCode")
}
}
}
.addOnFailureListener { e ->
Blog.LOGE("언어 감지 에러: ${e.message}")
}
}
// 💡 2. 감지된 언어를 바탕으로 한국어로 번역하는 함수
private fun startTranslationProcess(sourceLang: String) {
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLang)
.setTargetLanguage(TranslateLanguage.KOREAN) // 타겟은 무조건 한국어!
.build()
val translator = Translation.getClient(options)
Toast.makeText(this, "번역 모델 확인 중...", Toast.LENGTH_SHORT).show()
// 해당 언어 모델이 폰에 없으면 다운로드 (최초 1회, 약 30MB)
translator.downloadModelIfNeeded()
.addOnSuccessListener {
Toast.makeText(this, "자막 번역을 시작합니다!", Toast.LENGTH_SHORT).show()
// 💡 3. 수백~수천 줄의 자막을 백그라운드에서 한방에 번역 (UI 멈춤 방지)
CoroutineScope(Dispatchers.IO).launch {
for (block in externalSubtitles) {
try {
// ML Kit의 번역은 원래 비동기(Task)지만, Tasks.await를 쓰면 동기적으로 쫙 뽑아낼 수 있습니다.
val translated = com.google.android.gms.tasks.Tasks.await(translator.translate(block.text))
block.translatedText = translated
} catch (e: Exception) {
block.translatedText = block.text // 에러나면 원본 유지
}
}
saveTranslatedSubtitle()
withContext(Dispatchers.Main) {
Toast.makeText(this@PlayerActivity, "번역 완료 및 저장되었습니다.", Toast.LENGTH_SHORT).show()
}
}
}
.addOnFailureListener {
Toast.makeText(this, "번역 모델 다운로드 실패", Toast.LENGTH_SHORT).show()
}
}
private fun showSubtitleSelectionDialog() {
val trackNames = allSubtitleTracks.map { it.name }.toTypedArray()
AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert)
.setTitle("자막 선택")
.setCancelable(false)
.setItems(trackNames) { _, which ->
selectSubtitleTrack(allSubtitleTracks[which])
play()
}
.show()
}
private fun saveTranslatedSubtitle() {
try {
val originalFile = File(subtitlePath)
val newFileName = "${originalFile.nameWithoutExtension}_translated.srt"
val newFile = File(originalFile.parent, newFileName)
val srtContent = convertSubtitlesToSrt(externalSubtitles)
newFile.writeText(srtContent, Charsets.UTF_8)
// 💡 중요: 다음 로딩 시 이 파일을 찾을 수 있도록 경로 업데이트 시나리오 고려
Blog.LOGD(log = "번역 자막 저장 완료: ${newFile.absolutePath}")
} catch (e: Exception) {
Log.e("Player", "번역 파일 저장 실패", e)
}
}
override fun onDestroy() {
super.onDestroy()
nativePlayer?.destroy()
leftLongPressJob?.cancel()
subtitleSyncJob?.cancel()
uiUpdateJob?.cancel()
}
}
@@ -88,11 +88,11 @@ class NLService : NotificationListenerService() {
// 이름이 왔을 때 -> 주소록에서 번호 조회
val foundNumber = getPhoneNumberByName(applicationContext, cleanTitle)
if (foundNumber != null) {
Blog.LOGE("이름($title)으로 번호($foundNumber) 조회 성공")
// Blog.LOGE("이름($title)으로 번호($foundNumber) 조회 성공")
WorkersDb.logContactInteraction(foundNumber, UsageUpdateType.SMS)
} else {
// 주소록에도 없는 이름일 경우 (이름 자체로 로그를 남기려면 WorkersDb 수정 필요)
Blog.LOGE("주소록에서 찾을 수 없는 이름: $title")
// Blog.LOGE("주소록에서 찾을 수 없는 이름: $title")
}
}
}
@@ -159,7 +159,7 @@ class NLService : NotificationListenerService() {
val regex = Regex("[^가-힣ㄱ-ㅎ0-9a-zA-Z]")
val cleaned = input.replace(regex, "").trim()
Blog.LOGE("정제 전: [$input] -> 정제 후: [$cleaned]")
// Blog.LOGE("정제 전: [$input] -> 정제 후: [$cleaned]")
return cleaned
}
@@ -233,11 +233,10 @@ class NLService : NotificationListenerService() {
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
Blog.LOGE("onNotificationPosted ${sbn}")
// Blog.LOGE("onNotificationRemoved ${sbn}")
if (sbn.packageName == "bums.lunatic.launcher" && sbn.id == 830721) {
// 포그라운드 알림이 사라짐 감지
Blog.LOGE("NotificationListener", "포그라운드 알림 제거 감지")
// Blog.LOGE("NotificationListener", "포그라운드 알림 제거 감지")
// 서비스 재시작 시도
val intent = Intent(this, ForeGroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -35,12 +35,14 @@ import bums.lunatic.launcher.helpers.Constants.Companion.PREFS_SETTINGS
import bums.lunatic.launcher.helpers.PrefBoolean
import bums.lunatic.launcher.helpers.PrefHelper
import bums.lunatic.launcher.model.AppInfo
import bums.lunatic.launcher.model.QuoteItem
import bums.lunatic.launcher.model.SimpleContact
import bums.lunatic.launcher.settings.childs.Apps
import bums.lunatic.launcher.settings.childs.HomeSettings
import bums.lunatic.launcher.settings.childs.Misc
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.workers.WorkersDb
import bums.lunatic.launcher.workers.WorkersDb.getRealm
import kr.gdrive.bums.lunatic.utils.BackupPayload
import kr.gdrive.bums.lunatic.utils.GDriveBackupTask
import kr.gdrive.bums.lunatic.utils.GDriveLoginManager
@@ -175,6 +177,14 @@ internal class SettingsActivity : CommonActivity() {
}
}
fun getUntranslatedQuotesAsJson(): String {
val realm = getRealm()
val untranslatedList = realm.query<QuoteItem>("isTranslated == false").find()
// 리스트를 통째로 Gson을 사용해 JSON String으로 변환 (Logcat이나 파일로 출력)
return Gson().toJson(untranslatedList)
}
private fun updateUiForLoggedIn(email: String) {
binding.btnGoogleLogin.text = "연결 해제 ($email)"
binding.btnManualBackup.isEnabled = true
@@ -24,16 +24,27 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import bums.lunatic.launcher.R
import bums.lunatic.launcher.databinding.SettingsPrivitServiceBinding
import bums.lunatic.launcher.helpers.Constants.Companion.KEY_RSS_URL
import bums.lunatic.launcher.helpers.Constants.Companion.KEY_RSS_URL2
import bums.lunatic.launcher.helpers.PrefString
import bums.lunatic.launcher.settings.SettingsActivity.Companion.settingsPrefs
import bums.lunatic.launcher.utils.Blog
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.reflect.TypeToken
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Objects
import java.util.concurrent.TimeUnit
import kotlin.system.exitProcess
@@ -88,9 +99,30 @@ internal class Misc : SettingChild() {
PrefString.weatherApiKey.set(text.toString())
settingsChanged = true
}
binding.inputgemini.setText(PrefString.geminiApiKey.get(""))
binding.inputgemini.doOnTextChanged { text, start, before, count ->
text?.let {
if (text.length > 38 && isValidGeminiApiKey(text.toString())) {
PrefString.geminiApiKey.set(text.toString())
settingsChanged = true
testGeminiApiCommunication()
}
}
}
return binding.root
}
fun isValidGeminiApiKey(apiKey: String): Boolean {
val trimmedKey = apiKey.trim()
// 구글 API 키 표준 정규식 검사 (AIza로 시작하고 총 39자리)
val regex = Regex("^AIza[a-zA-Z0-9_\\-]{35}\$")
return regex.matches(trimmedKey)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(requireDialog() as BottomSheetDialog).dismissWithAnimation = true
@@ -106,4 +138,158 @@ internal class Misc : SettingChild() {
Objects.requireNonNull(binding.inputFeedUrl2.text).toString().trim { it <= ' ' }).apply()
}
data class GeminiResponse(val candidates: List<Candidate>)
data class Candidate(val content: Content)
data class Content(val parts: List<Part>)
data class Part(val text: String)
data class WallContentGroup(
val topic: String,
val translations: List<Translation>
)
data class Translation(
val lang: String,
val main: String,
val sub: String,
val pron: String
)
private val TEST_PROMPT = """
너는 여행 외국어 학습 전문가야. 해외 여행지나 일상적인 스몰토크에서 바로 사용할 있는 유용한 표현 5가지를 선정해줘.
표현에 대해 영어(EN), 스페인어(ES), 일본어(JA) 가지 버전으로 제공해야 .
중요 규칙:
- 모든 외국어 문장의 'pron' 필드에는 한국어 사용자가 읽기 편하도록 최대한 정확한 한국어 발음을 적어줘.
- 일본어(JA) 한자와 가나를 혼용하고, 'pron'에는 한글 발음을 적어줘.
- 주제(topic) '식당에서', ' 묻기', '자기소개', '날씨 이야기' 구체적으로 정해줘.
반드시 아래 JSON 배열 형식으로만 응답해:
[
{
"topic": "주제",
"translations": [
{"lang": "EN", "main": "Sentence", "sub": "한국어 뜻", "pron": "한글 발음"},
{"lang": "ES", "main": "Frase", "sub": "한국어 뜻", "pron": "한글 발음"},
{"lang": "JA", "main": "문장", "sub": "한국어 뜻", "pron": "한글 발음"}
]
}
]
""".trimIndent()
// 2. 테스트 함수
private fun testGeminiApiCommunication() {
val apiKey = PrefString.geminiApiKey.get("").trim()
if (apiKey.isEmpty()) {
Blog.LOGE("테스트 실패: API 키가 입력되지 않았습니다.")
return
}
// BottomSheetDialogFragment는 viewLifecycleOwner.lifecycleScope를 사용할 수 있습니다.
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
Blog.LOGE("==== Gemini API 통신 테스트 시작 ====")
val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간
.readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!)
.writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간
.build()
val listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey"
val listRequest = Request.Builder().url(listUrl).get().build()
val listResponse = client.newCall(listRequest).execute()
if (listResponse.isSuccessful) {
val listJson = listResponse.body?.string() ?: ""
val targetModelName = getBestModelName(listJson)
?: "models/gemini-1.5-flash" // 실패 시 기본값 fallback
Blog.LOGE("사용 결정된 모델명: $targetModelName")
val finalUrl = "https://generativelanguage.googleapis.com/v1beta/$targetModelName:generateContent?key=$apiKey"
Blog.LOGE("요청 URL: $finalUrl")
val requestMap = mapOf(
"contents" to listOf(
mapOf("parts" to listOf(mapOf("text" to TEST_PROMPT)))
),
"generationConfig" to mapOf(
"responseMimeType" to "application/json"
)
)
val jsonRequest = Gson().toJson(requestMap)
val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
val request = Request.Builder().url(finalUrl).post(body).build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val rawBody = response.body?.string() ?: ""
Blog.LOGE("1. 통신 성공! Raw Response 길이: ${rawBody.length}")
Blog.LOGE("Raw Data: $rawBody")
// 파싱 테스트
val gson = Gson()
val fullResponse = gson.fromJson(rawBody, GeminiResponse::class.java)
val innerJson = fullResponse.candidates[0].content.parts[0].text
val groupType = object : TypeToken<List<WallContentGroup>>() {}.type
val parsedList: List<WallContentGroup> = gson.fromJson(innerJson, groupType)
Blog.LOGE("2. 파싱 성공! 파싱된 세트 개수: ${parsedList.size}")
parsedList.forEachIndexed { index, group ->
Blog.LOGE(" [$index] 주제: ${group.topic} | 대표 문장(EN): ${group.translations.find { it.lang == "EN" }?.main}")
}
Blog.LOGE("==== 테스트 완료 ====")
} else {
Blog.LOGE("통신 실패: HTTP ${response.code} / ${response.message}")
Blog.LOGE("에러 바디: ${response.body?.string()}")
}
}
// 2. 최종 URL 조립 (모델명에 이미 'models/'가 포함되어 있으므로 경로 주의)
} catch (e: Exception) {
Blog.LOGE("통신 중 Exception 발생", e)
}
}
}
fun getBestModelName(jsonString: String): String? {
return try {
val gson = Gson()
Blog.LOGE("jsonString $jsonString")
val response = gson.fromJson(jsonString, GeminiModelListResponse::class.java)
Blog.LOGE("response $response")
// 1. 조건에 맞는 모델 필터링
// - 이름에 'gemini-1.5-flash'가 포함됨
// - 'generateContent' 기능을 지원함
val matchedModel = response.models.find { model ->
model.name.contains("flash") &&
model.supportedGenerationMethods.contains("generateContent")
}
// 2. 찾은 모델의 name 반환 (없으면 null)
matchedModel?.name
} catch (e: Exception) {
Blog.LOGE("모델 파싱 중 오류 발생", e)
null
}
}
}
data class GeminiModelListResponse(
val models: List<GeminiModelInfo>
)
// 개별 모델의 정보를 담는 클래스
data class GeminiModelInfo(
val name: String, // 예: "models/gemini-1.5-flash"
val version: String, // 예: "002"
val displayName: String,
val description: String,
val inputTokenLimit: Int,
val outputTokenLimit: Int,
val supportedGenerationMethods: List<String> // 예: ["generateContent", "countTokens"]
)
@@ -0,0 +1,74 @@
package bums.lunatic.launcher.utils
import org.mozilla.universalchardet.UniversalDetector
import java.io.File
import java.io.FileInputStream
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
object FileUtils {
val charsets = listOf("UTF-8", "GB18030", "CP949", "BIG5", "Shift_JIS", "CP949", "Shift_JIS", "ISO-8859-1", "Windows-1252", "EUC-KR","GB2312","65001" )
fun readTextWithEncoding(file: File): String {
val bytes = file.readBytes()
if (bytes.isEmpty()) return ""
// 1. BOM 체크는 가장 정확하므로 유지
if (bytes.size >= 3 && bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte()) {
return String(bytes, 3, bytes.size - 3, Charsets.UTF_8)
}
// 2. 인코딩 후보 순서 (이미지 같은 자막은 GB18030이 강력한 후보입니다)
var bestText = ""
for (charsetName in charsets) {
try {
val decoder = java.nio.charset.Charset.forName(charsetName).newDecoder()
// 💡 핵심: REPLACE 대신 REPORT를 사용해 엄격하게 검사합니다.
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
// 인코딩이 조금이라도 어긋나면 여기서 Exception이 발생해 다음으로 넘어갑니다.
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
// 💡 추가 검증: 특수 기호(Ã, Â) 비율이 너무 높으면 중국어/한국어일 확률이 큼
if (isNaturalText(text)) {
return text
}
bestText = text
} catch (e: Exception) {
continue // 다음 인코딩으로
}
}
return if (bestText.isNotEmpty()) bestText else String(bytes, Charsets.UTF_8)
}
// 💡 텍스트가 깨진 외계어인지 확인하는 보조 함수
private fun isNaturalText(text: String): Boolean {
val garbageCount = text.count { it == 'Ã' || it == 'Â' || it == 'æ' }
// 전체 텍스트에서 저런 문자가 3% 이상 섞여있다면 깨진 것으로 간주
return garbageCount < (text.length / 30)
}
fun detectFileEncoding(file: File): String {
val detector = UniversalDetector(null)
FileInputStream(file).use { fis ->
val buf = ByteArray(4096)
var nread: Int
while (fis.read(buf).also { nread = it } > 0 && !detector.isDone) {
detector.handleData(buf, 0, nread)
}
detector.dataEnd()
}
// 감지된 인코딩이 없으면 기본값으로 UTF-8 또는 CP949를 반환합니다.
return detector.detectedCharset ?: "UTF-8"
}
}
@@ -15,13 +15,13 @@ object RssList {
"https://www.youtube.com/@fairyjaehyung",
"https://www.youtube.com/@15ya_egg",
"https://www.youtube.com/@%EC%A7%80%EB%AC%B4%EB%B9%84",
"https://www.youtube.com/"
// "https://www.youtube.com/"
)
val newsFeeds = arrayListOf(
"https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko",
"https://rss.nocutnews.co.kr/news/top.xml",
// "https://rss.nocutnews.co.kr/news/top.xml",
"https://trends.google.co.kr/trending/rss?geo=KR",
"https://trends.google.co.kr/trends/trendingsearches/daily/rss?geo=KR"
// "https://trends.google.co.kr/trends/trendingsearches/daily/rss?geo=KR"
)
val feedJsons = arrayListOf(
@@ -35,7 +35,9 @@ class LunaticBrowserLayout @JvmOverloads constructor(
context.startActivity(Intent.createChooser(intent, "공유"))
}
}
binding.tvTitle.setOnClickListener {
geckoWeb.scrollTop()
}
// GeckoWeb 내부에서 주소 등 업데이트 시 연동되도록 등록
geckoWeb.decoViews.addAll(listOf(
binding.tvAddress, binding.btnDlVideo, binding.btnReload, binding.btnBack, binding.tvTitle
@@ -1,22 +1,59 @@
package bums.lunatic.launcher.wall
import android.app.ActivityManager
import android.app.WallpaperManager
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Environment
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PathDashPathEffect
import android.graphics.PixelFormat
import android.graphics.RadialGradient
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.Typeface.BOLD
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.BatteryManager
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.service.wallpaper.WallpaperService
import android.text.SpannableStringBuilder
import android.util.Log
import android.view.Gravity
import android.view.SurfaceHolder
import androidx.work.ListenableWorker
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.graphics.alpha
import bums.lunatic.launcher.R
import bums.lunatic.launcher.model.QuoteItem
import bums.lunatic.launcher.model.Translation
import bums.lunatic.launcher.model.WallContentGroup
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.workers.LocationUpdateService
import bums.lunatic.launcher.workers.WorkersDb
import com.google.common.reflect.TypeToken
import com.google.gson.Gson
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MyWallpaperService : WallpaperService() {
private val TAG = "MyWallpaperService"
override fun onCreateEngine(): Engine {
@@ -24,6 +61,60 @@ class MyWallpaperService : WallpaperService() {
return NativeRenderEngine()
}
data class BatteryInfo(
val percent: Int,
val isCharging: Boolean,
// val speedText: String,
val detailsText: String // "9.0V 1.5A (13.5W)" 같은 상세 정보
)
private fun getBatteryStatus(): BatteryInfo {
val intent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
val batteryPct = (level / scale.toFloat() * 100).toInt()
val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
// var speedText = ""
var detailsText = ""
if (isCharging) {
// speedText = when (plugged) {
// BatteryManager.BATTERY_PLUGGED_AC -> "고속"
// BatteryManager.BATTERY_PLUGGED_USB -> "저속"
// BatteryManager.BATTERY_PLUGGED_WIRELESS -> "무선"
// else -> ""
// }
// 1. 전압(Voltage) 가져오기: 기본 단위는 밀리볼트(mV)
val voltageMv = intent?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) ?: 0
val voltageV = voltageMv / 1000.0f
// 2. 전류(Current) 가져오기: BatteryManager를 통해 실시간 값 조회
val batteryManager = getSystemService(BATTERY_SERVICE) as BatteryManager
// 안드로이드 표준 단위는 마이크로암페어(µA) 입니다.
val currentUa = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
// 음수(방전)로 나올 수 있으므로 절대값 처리 후 암페어(A)로 변환
val currentA = Math.abs(currentUa) / 1000000.0f
// 3. 전력(Watt) 계산: W = V * A
val wattage = voltageV * currentA
// 값이 정상적으로 읽혔을 때만 텍스트 생성 (가끔 0으로 떨어지는 기기 방어)
if (voltageV > 0f && currentA > 0f) {
detailsText = String.format("%.1fV,%.1fA,(%.1fW)", voltageV, currentA, wattage)
}
}
return BatteryInfo(batteryPct, isCharging, detailsText)
}
inner class NativeRenderEngine : Engine() {
private lateinit var handlerThread: HandlerThread
private lateinit var handler: Handler
@@ -69,6 +160,16 @@ class MyWallpaperService : WallpaperService() {
stopRendering()
}
}
private val nextMediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == "ACTION_NEXT_WALLPAPER") {
Log.d(TAG, "Manual next wallpaper requested via Broadcast")
// 현재 콜백 객체의 함수를 직접 호출하여 다음 미디어로 넘김
nextMediaCallback.onNextMediaRequested()
nativeRenderer?.forceNext()
}
}
}
override fun onCreate(surfaceHolder: SurfaceHolder) {
super.onCreate(surfaceHolder)
@@ -76,7 +177,12 @@ class MyWallpaperService : WallpaperService() {
Log.d(TAG, "onCreate: Engine created.")
wasInPreview = isPreview()
Log.d(TAG, "onCreate: Engine created. Initial isPreview = $wasInPreview")
val filter = IntentFilter("ACTION_NEXT_WALLPAPER")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
registerReceiver(nextMediaReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(nextMediaReceiver, filter)
}
ensureHandlerThread()
}
@@ -134,6 +240,16 @@ class MyWallpaperService : WallpaperService() {
initializeRenderer()
}
requestSyncRenderState()
if (visible) {
// 화면이 켜지면 즉시 업데이트하고 타이머 시작
handler.removeCallbacks(updateWeatherRunnable)
handler.post(updateWeatherRunnable)
} else {
// 화면이 꺼지면 타이머 중지
handler.removeCallbacks(updateWeatherRunnable)
}
}
// --- ⬇️ 상태 동기화를 요청하는 함수 추가 ⬇️ ---
@@ -159,6 +275,11 @@ class MyWallpaperService : WallpaperService() {
if (::handlerThread.isInitialized) {
handlerThread.quitSafely()
}
try {
unregisterReceiver(nextMediaReceiver)
} catch (e: Exception) {
// 리시버가 등록되지 않았을 경우 예외 방지
}
}
@@ -187,60 +308,469 @@ class MyWallpaperService : WallpaperService() {
Log.d(TAG, "Initializing Renderer...")
nativeRenderer = NativeRenderer()
nativeRenderer?.initialize() // nativeInit() -> initialize()
nativeRenderer?.setFadeDuration(1500)
nativeRenderer?.setFadeDuration(3000)
nativeRenderer?.setTurnPageDuration(8000)
nativeRenderer?.setAnimationMode(NativeRenderer.ANIMATION_MODE_PAN)
nativeRenderer?.setTransitionMode(NativeRenderer.TRANSITION_MODE_FADE)
nativeRenderer?.setAnimationSpeed(1.0f)
nativeRenderer?.setImageRenderFrame(70)
NativeRenderer.nativeSetNextMediaCallback(nextMediaCallback)
if (mediaFiles.isEmpty()) {
handler.post { loadMediaFiles() }
}
handler.removeCallbacks(updateWeatherRunnable)
// 즉시 첫 번째 비트맵 생성 및 전송
handler.post(updateWeatherRunnable)
}
// ... loadMediaFiles, nextMediaCallback, getFdFromPath는 이전과 동일 ...
private fun loadMediaFiles() {
val requiredSizeRatio = 0.5
private fun getVideoSize(file: File): Pair<Int, Int>? {
val retriever = android.media.MediaMetadataRetriever()
return try {
retriever.setDataSource(file.absolutePath)
val width = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
val height = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
Pair(width, height)
} catch (e: Exception) {
null
} finally {
retriever.release()
}
}
fun loadFiles() {
if (!mediaDir.exists()) mediaDir.mkdirs()
// mediaFiles = mediaDir.listFiles()
// ?.filter { supportedExtensions.contains(it.extension.lowercase()) }
// ?.toList() ?: emptyList()
Log.d(TAG, "Found ${mediaFiles.size} media files.")
val allFiles = mediaDir.listFiles()
val allFiles = mediaDir.listFiles()?.filter { supportedExtensions.contains(it.extension.lowercase()) } ?: return
val trashFolder = File(mediaDir, "low_res_backup")
if (!trashFolder.exists()) trashFolder.mkdirs()
val invalidImages = mutableListOf<File>()
val wm = WallpaperManager.getInstance(this@MyWallpaperService)
val minWidth = wm.desiredMinimumWidth
val minHeight = wm.desiredMinimumHeight
val requiredSize = Math.max(minWidth, minHeight).times(0.6)
val requiredSize = Math.max(wm.desiredMinimumWidth, wm.desiredMinimumHeight) * requiredSizeRatio
val videoExtensions = listOf("mp4", "mkv", "avi", "mov", "webm", "gif")
val imageExtensions = listOf("jpg", "jpeg", "png", "bmp", "webp")
for (file in allFiles) {
if (file.isFile && (file.extension.equals("jpg", true) ||
file.extension.equals("png", true) ||
file.extension.equals("jpeg", true) ||
file.extension.equals("bmp", true) || // BMP 추가
file.extension.equals("webp", true))) {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, options)
Blog.LOGE("requiredSize ${requiredSize} w :${options.outWidth} , h : ${options.outHeight}")
if (options.outWidth >= requiredSize && options.outHeight >= requiredSize) {
mediaFiles.add(file)
} else {
invalidImages.add(file) // 조건 미달
val ext = file.extension.lowercase()
var width = 0
var height = 0
var isOk = false
when {
// 이미지 처리
imageExtensions.contains(ext) -> {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, options)
width = options.outWidth
height = options.outHeight
// Blog.LOGE("loadFiles imageExtensions width $width height $height")
}
// 동영상 처리
videoExtensions.contains(ext) -> {
val size = getVideoSize(file)
width = size?.first ?: 0
height = size?.second ?: 0
isOk = (width >= requiredSize * 0.65 && height >= requiredSize*0.65)
// Blog.LOGE("loadFiles videoExtensions requiredSize $requiredSize width $width height $height [$isOk]")
}
}
// 사이즈 검사 및 분류
if (isOk || (width >= requiredSize && height >= requiredSize)) {
if (!mediaFiles.contains(file)) mediaFiles.add(file)
} else {
// Log.w(TAG, "Low resolution file filtered: ${file.name} (${width}x${height})")
file.renameTo(File(trashFolder, file.name))
}
}
}
private fun getRamUsage(): String {
val mi = ActivityManager.MemoryInfo()
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(mi)
val availableMegs = mi.availMem / 1048576L
val totalMegs = mi.totalMem / 1048576L
val usedMegs = totalMegs - availableMegs
val percent = (usedMegs.toDouble() / totalMegs.toDouble() * 100).toInt()
return "RAM: $percent%"
}
// 배터리/기기 온도 가져오기 (Celsius)
private fun getDeviceTemperature(): String {
val intent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val temp = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
return "Temp: ${temp / 10.0}°C"
}
private fun getSystemTypeface(lang: String): Typeface {
return when (lang) {
"JA" -> Typeface.create("sans-serif", Typeface.BOLD) // 일본어 한자/가나 대응
else -> Typeface.create("sans-serif-medium", Typeface.NORMAL) // 영문/스페인어
}
}
private val materialIconFont: Typeface by lazy {
resources.getFont(R.font.material_symbols)
}
// 날씨 텍스트를 분석하여 Material Symbol 아이콘 문자열로 변환하는 함수
private fun getWeatherIconString(condition: String?): String {
if (condition == null) return "thermostat"
val lower = condition.lowercase()
return when {
// 1. 천둥/번개 (우선순위 최상: 비나 눈이 와도 번개가 치면 뇌우 아이콘)
lower.contains("thunder") -> "thunderstorm"
// 2. 눈, 우박, 진눈깨비, 빙결 (Snow, Ice pellets, Sleet, Blizzard, Freezing)
lower.contains("snow") || lower.contains("blizzard") ||
lower.contains("ice") || lower.contains("sleet") ||
lower.contains("freezing") -> "snowing"
// 3. 비, 소나기, 이슬비 (Rain, Drizzle, Shower)
lower.contains("rain") || lower.contains("drizzle") ||
lower.contains("shower") -> "rainy"
// 4. 안개 (Fog, Mist)
lower.contains("fog") || lower.contains("mist") -> "foggy"
// 5. 구름 (Overcast, Cloudy, Partly cloudy)
lower.contains("partly cloudy") -> "partly_cloudy_day"
lower.contains("cloudy") || lower.contains("overcast") -> "cloud"
// 6. 맑음 (Sunny, Clear)
lower.contains("sunny") || lower.contains("clear") -> "sunny"
// 7. 예외 처리 (매핑되지 않은 기본값)
else -> "thermostat"
}
}
private fun getKoreanWeatherCondition(condition: String): String {
val lower = condition.trim().lowercase()
return when {
lower == "sunny" || lower == "clear" -> "맑음"
lower == "partly cloudy" -> "구름 조금"
lower == "cloudy" -> "구름 많음"
lower == "overcast" -> "흐림"
lower.contains("mist") || lower.contains("fog") -> "안개"
lower.contains("blizzard") || lower.contains("blowing snow") -> "눈보라"
// 뇌우(번개) 계열
lower.contains("thunder") && lower.contains("snow") -> "천둥/눈"
lower.contains("thunder") && lower.contains("rain") -> "천둥/비"
lower.contains("thunder") -> "뇌우"
// 얼음 섞인 비/눈 계열
lower.contains("sleet") -> "진눈깨비"
lower.contains("ice pellets") -> "싸락눈"
// 비 계열 (우선순위: 폭우 -> 소나기 -> 강한 비 -> 이슬비 -> 비)
lower.contains("torrential") -> "폭우"
lower.contains("heavy") && lower.contains("shower") -> "강한 소나기"
lower.contains("shower") -> "소나기"
lower.contains("heavy rain") -> "강한 비"
lower.contains("drizzle") -> "이슬비"
lower.contains("rain") -> ""
// 눈 계열 (우선순위: 폭설 -> 눈)
lower.contains("heavy snow") -> "폭설"
lower.contains("snow") -> ""
// 매핑되지 않은 기본값 (혹시 모를 예외 대비)
else -> condition
}
}
val fontz = arrayListOf<Int>(
R.font.cafe24ohsquareair,
R.font.cafe24oneprettynight,
R.font.cafe24ssukssukregular,
R.font.dovemayo,
R.font.ebs_r,
R.font.gabia_solmee,
R.font.godomaum,
R.font.jsarirang_hon,
R.font.jsarirang_ppuri,
R.font.jsdongkang_regular,
R.font.kcc_kimhoon,
R.font.kcc_sonkeechung,
R.font.kccahnjunggeun,
R.font.kotra_bold,
R.font.kotra_songeulssi,
R.font.kyobo_handwriting_2019,
R.font.kyobo_handwriting_2021sjy,
R.font.kyobohandwriting2024psw,
R.font.mapoagape,
R.font.mapobackpacking,
R.font.mapodacapo,
R.font.mapodpp,
R.font.mapodpp_2,
R.font.mapoflowerisland,
R.font.mapogoldenpier,
R.font.mapomaponaru,
R.font.mapopeacefull,
R.font.material_symbols,
R.font.nnsgc_brhp,
R.font.nnsgc_gd_an_gd,
R.font.nnsgc_md,
R.font.nnsgc_wsjidyp,
R.font.nnsgc_yjc,
R.font.on_jsuhl,
R.font.on_jsuhr,
R.font.on_sbsjl,
R.font.on_sbsjr,
R.font.on_treeususimgul,
R.font.on_treeususimgul_r,
R.font.on_wibsr,
R.font.on_wisbl,
R.font.on_ychyuhl,
R.font.on_ychyuhr,
R.font.ssshinb7,
R.font.taebaek_milkyway,
R.font.taefont_tsthlml,
R.font.tvn_jguiyg_light,
R.font.tvn_jguiyg_medium,
R.font.wandohoper,
R.font.ylee_mortal_heart_immortal_memory,
)
var monoBold = resources.getFont(fontz.random())
var quote = resources.getFont(fontz.random())
var local = resources.getFont(fontz.random())
var numbers = resources.getFont(fontz.random())
private fun drawBitmapFromText(weatherLines: List<String>, isCharging: Boolean = false): Bitmap? {
// 💡 1. 원의 크기와 내부 여백 계산
val displayMetrics = resources.displayMetrics
var sizeRate = 1.1f
val circleSize = ((displayMetrics.widthPixels * 0.48f).toInt() * sizeRate).toInt()
val padding = 5
val maxContentWidth = circleSize - (padding * 2)
if (circleSize <= 0) return null
// 💡 2. 텍스트 크기 3단계 변수 정의 (가독성과 감성을 모두 잡은 안정적인 비율)
val iconRate = 1.3f
val sizeLarge = 24f // 메인 날씨 상태
val sizeMedium = 15f // 날짜, 주소, 온도, 명언 본문
val sizeSmall = 13f // 시스템 정보, 명언 번역, 작가
// 3. 데이터 준비
val dateFull = SimpleDateFormat("yyyy.MM.dd(E)", Locale.ENGLISH).format(Date())
val weatherCondition = if (weatherLines.isNotEmpty()) weatherLines[0] else ""
val weatherTemp = if (weatherLines.size > 1) weatherLines[1] else ""
val weatherHum = if (weatherLines.size > 2) weatherLines[2] else ""
val address = if (weatherLines.size > 3) weatherLines[3] else ""
val wrapContent = ViewGroup.LayoutParams.WRAP_CONTENT
// 4. 뷰 생성 헬퍼
fun createTextView(textToSet: CharSequence, textSizeDp: Float, font: Typeface, alphaValue: Float = 1.0f): TextView {
return TextView(this@MyWallpaperService).apply {
layoutParams = LinearLayout.LayoutParams(wrapContent, wrapContent)
maxWidth = maxContentWidth
text = textToSet
textSize = textSizeDp
typeface = font
setTextColor(Color.WHITE)
alpha = alphaValue
gravity = Gravity.CENTER
setShadowLayer(6f, 3f, 3f, Color.parseColor("#AA000000"))
}
}
// 2. 부적합 이미지 이동 처리 (Job 밖에서 따로 돌려도 무방)
invalidImages.forEach { file ->
val targetFile = File(trashFolder, file.name)
file.renameTo(targetFile) // 파일 이동
fun createHorizontalLayout(): LinearLayout {
return LinearLayout(this@MyWallpaperService).apply {
layoutParams = LinearLayout.LayoutParams(wrapContent, wrapContent)
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
setPadding(1, 1, 1, 1)
}
}
val sysAlpha = 0.9f
// 5. 각 줄(Row) 생성
val rowsToArrange = mutableListOf<View>()
// ① 날씨 (아이콘 + 상태)
if (weatherCondition.isNotEmpty()) {
val weatherRow = createHorizontalLayout()
// 아이콘은 Large 텍스트보다 살짝 크게 배율 고정
weatherRow.addView(createTextView(getWeatherIconString(weatherCondition), sizeLarge * iconRate, materialIconFont))
weatherRow.addView(createTextView(" " + getKoreanWeatherCondition(weatherCondition), sizeLarge, monoBold))
rowsToArrange.add(weatherRow)
}
// ② 온도 & 습도
if (weatherTemp.isNotEmpty()) {
val tempRow = createHorizontalLayout()
val tempStr = weatherTemp.trim()
val humStr = weatherHum.trim()
tempRow.addView(createTextView("thermostat", sizeMedium * iconRate, materialIconFont, sysAlpha))
tempRow.addView(createTextView(tempStr, sizeMedium, numbers,sysAlpha))
if (humStr.isNotEmpty()) {
tempRow.addView(createTextView("water_drop", sizeMedium * iconRate, materialIconFont, sysAlpha))
tempRow.addView(createTextView(humStr, sizeMedium, numbers, sysAlpha))
}
rowsToArrange.add(tempRow)
}
// ③ 주소
if (address.isNotEmpty()) {
rowsToArrange.add(createTextView(address, sizeMedium, local, sysAlpha))
}
// ④ 날짜
rowsToArrange.add(createTextView(dateFull, sizeMedium, local))
// ⑤ 시스템 정보
val sysInfoRow = createHorizontalLayout()
val ramValue = getRamUsage().replace("RAM: ", "")
sysInfoRow.addView(createTextView("memory", sizeSmall * iconRate, materialIconFont, sysAlpha))
sysInfoRow.addView(createTextView(ramValue, sizeSmall, numbers, sysAlpha).apply { setPadding(0, 0, 15, 0) })
val tempValue = getDeviceTemperature().replace("Temp: ", "")
sysInfoRow.addView(createTextView("device_thermostat", sizeSmall * iconRate, materialIconFont, sysAlpha))
sysInfoRow.addView(createTextView(tempValue, sizeSmall, numbers, sysAlpha).apply { setPadding(0, 0, 15, 0) })
val batteryInfo = getBatteryStatus()
val batteryIcon = if (batteryInfo.isCharging) "battery_charging_full" else "battery_full"
val batteryText = "${batteryInfo.percent}%"
sysInfoRow.addView(createTextView(batteryIcon, sizeSmall * iconRate, materialIconFont, sysAlpha))
sysInfoRow.addView(createTextView(batteryText, sizeSmall, numbers, sysAlpha))
rowsToArrange.add(sysInfoRow)
// ⑥ 명언
randomQuote?.let { randomQuote ->
val builder = SpannableStringBuilder()
fun appendSpanned(text: String, sizeDp: Float, font: Typeface, alpha: Float) {
val start = builder.length
builder.append(text)
val end = builder.length
builder.setSpan(android.text.style.AbsoluteSizeSpan(sizeDp.toInt(), true), start, end, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val alphaInt = (alpha * 255).toInt()
val color = Color.argb(alphaInt, 255, 255, 255)
builder.setSpan(android.text.style.ForegroundColorSpan(color), start, end, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.setSpan(object : android.text.style.MetricAffectingSpan() {
override fun updateDrawState(tp: android.text.TextPaint) { tp.typeface = font }
override fun updateMeasureState(tp: android.text.TextPaint) { tp.typeface = font }
}, start, end, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (randomQuote.isTranslated) {
appendSpanned("\"${randomQuote.textEn}\"", sizeMedium, quote, 1.0f)
builder.append("\n")
appendSpanned(randomQuote.textKo, sizeSmall, monoBold, sysAlpha)
val author = if (randomQuote.authorKo.isNotEmpty()) randomQuote.authorKo else randomQuote.authorEn
appendSpanned(" - $author -", sizeSmall, monoBold, sysAlpha)
} else {
val mainText = if (randomQuote.textKo.isNotEmpty()) randomQuote.textKo else randomQuote.textEn
val author = if (randomQuote.authorKo.isNotEmpty()) randomQuote.authorKo else randomQuote.authorEn
appendSpanned("\"$mainText\"", sizeMedium, quote, 1.0f)
appendSpanned(" - $author -", sizeSmall, monoBold, sysAlpha)
}
val quoteView = createTextView(builder, sizeMedium, quote, 1.0f).apply {
// setLineSpacing(0f, 1.2f)
}
rowsToArrange.add(quoteView)
}
// 💡 6. 정렬 및 다이아몬드 배열
val widthSpec = View.MeasureSpec.makeMeasureSpec(maxContentWidth, View.MeasureSpec.AT_MOST)
val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
rowsToArrange.forEach { it.measure(widthSpec, heightSpec) }
val sortedRows = rowsToArrange.sortedBy { it.measuredWidth }
val arrangedRows = arrayOfNulls<View>(sortedRows.size)
var topIndex = 0
var bottomIndex = sortedRows.size - 1
for (i in sortedRows.indices) {
if (i % 2 == 0) arrangedRows[topIndex++] = sortedRows[i]
else arrangedRows[bottomIndex--] = sortedRows[i]
}
// 7. 35% 고정 크기 원형 틀 생성
val rootLayout = LinearLayout(this@MyWallpaperService).apply {
layoutParams = ViewGroup.LayoutParams(circleSize, circleSize)
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
background = WavyOvalDrawable(
bgColor = Color.parseColor("#40000000"),
strokeColor = Color.parseColor("#09000000"),
strokeThick = 40f
)
}
arrangedRows.forEach { rowView ->
if (rowView != null) {
val lp = LinearLayout.LayoutParams(wrapContent, wrapContent).apply {
setMargins(0, 2, 0, 2)
}
rootLayout.addView(rowView, lp)
}
}
// 8. 강제 측정, 렌더링 및 캡처
val exactSpec = View.MeasureSpec.makeMeasureSpec(circleSize, View.MeasureSpec.EXACTLY)
rootLayout.measure(exactSpec, exactSpec)
rootLayout.layout(0, 0, circleSize, circleSize)
val bitmap = Bitmap.createBitmap(circleSize, circleSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
rootLayout.draw(canvas)
return bitmap
}
private val updateWeatherRunnable = object : Runnable {
override fun run() {
if (!isVisible) return
val (batteryLevel, isCharging) = getBatteryStatus()
// --- ⬇️ 제안하신 조건별 로직 적용 ⬇️ ---
var nextDelay = 30000L // 기본 20초
if (isCharging) {
nextDelay = 5000L // 충전 중이거나 70% 이상이면 10초
nativeRenderer?.setImageRenderFrame(45)
} else if (batteryLevel >= 80) {
nativeRenderer?.setImageRenderFrame(70)
} else {
handler.removeCallbacks(this)
return
}
// 비트맵 생성 및 전달
val weather = LocationUpdateService.lastWeather
val bitmap = drawBitmapFromText(weather,isCharging)
bitmap?.let {
nativeRenderer?.setWeatherBitmap(it)
}
handler.postDelayed(this, nextDelay)
}
}
private fun loadMediaFiles() {
loadFiles()
if (mediaFiles.isNotEmpty()) {
val initialFile = mediaFiles.random()
Log.d(TAG, "Attempting to load initial random media via preloader: ${initialFile.absolutePath}")
@@ -249,63 +779,46 @@ class MyWallpaperService : WallpaperService() {
} ?: run {
Log.e(TAG, "Failed to get fd for initial media: ${initialFile.absolutePath}")
}
}
}
val mediaDir = File(File(this@MyWallpaperService.getExternalFilesDir(null), "completed_torrents"), "Images")
val supportedExtensions = listOf("mp4", "mkv", "avi", "mov","webm", "jpg", "jpeg", "png", "bmp", "webp", "gif")
var randomQuote : QuoteItem? = null
private val nextMediaCallback = object : NativeRenderer.NextMediaCallback {
override fun onNextMediaRequested() {
if (!mediaDir.exists()) mediaDir.mkdirs()
// mediaFiles = mediaDir.listFiles()
// ?.filter { supportedExtensions.contains(it.extension.lowercase()) }
// ?.toList() ?: emptyList()
val allFiles = mediaDir.listFiles()
val trashFolder = File(mediaDir, "low_res_backup")
if (!trashFolder.exists()) trashFolder.mkdirs()
// val validImages = mutableListOf<File>()
val invalidImages = mutableListOf<File>()
val wm = WallpaperManager.getInstance(this@MyWallpaperService)
val minWidth = wm.desiredMinimumWidth
val minHeight = wm.desiredMinimumHeight
val requiredSize = Math.max(minWidth, minHeight).times(0.6)
for (file in allFiles) {
if (file.isFile && (file.extension.equals("jpg", true) ||
file.extension.equals("png", true) ||
file.extension.equals("jpeg", true) ||
file.extension.equals("bmp", true) || // BMP 추가
file.extension.equals("webp", true))) {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, options)
Blog.LOGE("requiredSize ${requiredSize} w :${options.outWidth} , h : ${options.outHeight}")
if (options.outWidth >= requiredSize && options.outHeight >= requiredSize) {
mediaFiles.add(file)
} else {
invalidImages.add(file) // 조건 미달
}
}
}
// 2. 부적합 이미지 이동 처리 (Job 밖에서 따로 돌려도 무방)
invalidImages.forEach { file ->
val targetFile = File(trashFolder, file.name)
file.renameTo(targetFile) // 파일 이동
}
loadFiles()
val nextFile = mediaFiles.random()
Log.d(TAG, "Callback: Preloading next random media: ${nextFile.absolutePath}")
// ⑥ 명언 (DB 랜덤 추출)
val realm = WorkersDb.getRealm()
val quotes = realm.query<QuoteItem>(QuoteItem::class).find().shuffled()
randomQuote = if (quotes.isNotEmpty()) quotes.random() else null
monoBold = resources.getFont(fontz.random())
quote = resources.getFont(fontz.random())
local = resources.getFont(fontz.random())
numbers = resources.getFont(fontz.random())
getFdFromPath(nextFile.absolutePath)?.let { fd ->
nativeRenderer?.startNextPreload(fd)
} ?: run {
Log.e(TAG, "Callback: Failed to get fd for ${nextFile.absolutePath}")
}
val (batteryLevel, isCharging) = getBatteryStatus()
if (batteryLevel >= 70 || isCharging) {
val bitmap = drawBitmapFromText(LocationUpdateService.lastWeather)
bitmap?.let { nativeRenderer?.setWeatherBitmap(it) }
nativeRenderer?.setImageRenderFrame(55)
Log.d(TAG, "Battery Low: Weather updated only on media change.")
} else {
nativeRenderer?.setImageRenderFrame(99)
nativeRenderer?.setWeatherBitmap(null)
}
if (isCharging || batteryLevel > 90) {
// handler.post(updateWeatherRunnable)
}
}
}
@@ -344,4 +857,84 @@ class MyWallpaperService : WallpaperService() {
}
}
}
}
class WavyOvalDrawable(
private val bgColor: Int,
private val strokeColor: Int,
private val strokeThick: Float
) : Drawable() {
private val waveLength = 40f
private val amplitude = 6f
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
// 단일 색상이 아닌 Shader(그라데이션)를 사용할 것이므로 style만 지정
style = Paint.Style.FILL
}
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = strokeColor
style = Paint.Style.STROKE
strokeWidth = 100f
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
val wavePath = Path().apply {
moveTo(0f, 0f)
quadTo(waveLength / 4, amplitude, waveLength / 2, 0f)
quadTo(waveLength * 3 / 4, -amplitude, waveLength, 0f)
}
pathEffect = PathDashPathEffect(wavePath, waveLength, 0f, PathDashPathEffect.Style.MORPH)
}
// 💡 뷰의 크기(Bounds)가 결정되거나 변경될 때 호출됩니다.
// 여기서 크기에 맞는 그라데이션을 계산하여 페인트에 입힙니다.
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
val centerX = bounds.exactCenterX()
val centerY = bounds.exactCenterY()
val radius = Math.max(bounds.width(), bounds.height()) / 2f
if (radius > 0) {
// 바깥쪽 색상: 입력받은 bgColor에서 투명도(Alpha)만 0으로 날려버린 색
// 이렇게 해야 검은색->흰색으로 깨지는 현상 없이 자연스럽게 투명해집니다.
val transparentColor = bgColor and 0x10FFFFFF
bgPaint.shader = RadialGradient(
centerX,
centerY,
radius,
intArrayOf(bgColor, transparentColor), // 중앙 -> 바깥 색상 배열
floatArrayOf(0.4f, 1.1f), // 중심에서 40% 지점부터 투명해지기 시작
Shader.TileMode.CLAMP
)
}
}
override fun draw(canvas: Canvas) {
val rectF = RectF(bounds)
// 2. 선 잘림 방지용 여백 계산
val inset = 8f
rectF.inset(inset, inset)
// 3. 물결 테두리 선 그리기
canvas.drawOval(rectF, strokePaint)
// 1. 방사형 그라데이션 배경 채우기
canvas.drawOval(rectF, bgPaint)
}
override fun setAlpha(alpha: Int) {
bgPaint.alpha = alpha
strokePaint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
@@ -1,5 +1,6 @@
package bums.lunatic.launcher.wall
import android.graphics.Bitmap
import android.util.Log
import android.view.Surface
@@ -29,13 +30,23 @@ open class NativeRenderer {
nativeHandle = 0L
}
}
fun setWeatherBitmap(bitmap: Bitmap?) {
if (nativeHandle != 0L) {
nativeSetWeatherBitmap(nativeHandle, bitmap)
}
}
fun render(surface: Surface) {
if (nativeHandle != 0L) {
nativeRender(nativeHandle, surface)
}
}
fun forceNext() {
if (nativeHandle != 0L) {
nativeForceNext(nativeHandle)
}
}
fun startNextPreload(fd: Int) {
if (nativeHandle != 0L) {
nativeStartNextPreload(nativeHandle, fd)
@@ -96,19 +107,30 @@ open class NativeRenderer {
}
}
fun setImageRenderFrame(frame:Int) {
if (nativeHandle != 0L) {
nativeSetImageRenderFrame(nativeHandle, frame)
} else {
"Kotlin Wrapper: Native handle is null."
}
}
// --- Private JNI declarations ---
private external fun nativeStartRenderLoop(nativeHandle: Long, surface: Surface)
private external fun nativeStopRenderLoop(nativeHandle: Long)
private external fun nativeInit(): Long
private external fun nativeDestroy(nativeHandle: Long)
private external fun nativeRender(nativeHandle: Long, surface: Surface)
private external fun nativeSetWeatherBitmap(nativeHandle: Long, bitmap: Bitmap?)
private external fun nativeStartNextPreload(nativeHandle: Long, fd: Int)
private external fun nativeSetImageRenderFrame(nativeHandle: Long, frame: Int)
private external fun nativeSetAnimationSpeed(nativeHandle: Long, speed: Float)
private external fun nativeSetAnimationMode(nativeHandle: Long, mode: Int)
private external fun nativeSetFadeDuration(nativeHandle: Long, duration: Int)
private external fun nativeGetDebugInfo(nativeHandle: Long): String
private external fun nativeSetPageTurnDelay(nativeHandle: Long, duration: Int)
private external fun nativeSetTransitionMode(nativeHandle: Long, mode: Int)
private external fun nativeForceNext(nativeHandle: Long)
// --- Companion Object ---
companion object {
@@ -24,6 +24,8 @@ abstract class BaseGetter(internal val context: Context) {
}
}
val USAGT = "Mozilla/5.0 (Android 15; Mobile; rv:141.0) Gecko/141.0 Firefox/141.0"
val limitDateTime = beforeOneDay()
@@ -32,6 +34,7 @@ abstract class BaseGetter(internal val context: Context) {
abstract fun realWork() : List<RealmObject>
open suspend fun fetchData(): List<RealmObject> {
return realWork()
}
}
@@ -41,6 +41,7 @@ class LocationUpdateService : Service(), LocationListener {
companion object {
var longitude: Double = 0.0
var lastWeather = mutableListOf<String>()
var latitude: Double = 0.0
fun pushLocation(context: Context, lat :Double, long : Double) {
try {
@@ -48,6 +49,7 @@ class LocationUpdateService : Service(), LocationListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
geocoder.getFromLocation(lat, long, 1) { addresses ->
addresses.first()?.let {
addresses.get(0)
WorkersDb.getRealm()?.apply {
LocationLog().let { loc ->
loc.fillData(it)
@@ -99,6 +101,43 @@ class LocationUpdateService : Service(), LocationListener {
e.printStackTrace()
}
}, 5, TimeUnit.SECONDS)
Blog.LOGE("addresses ${addresses.first()}")
Executors.newSingleThreadScheduledExecutor().schedule({
try {
val url = "https://api.weatherapi.com/v1/current.json?key=${PrefString.weatherApiKey.get()}&q=${lat},${long}&aqi=no"
if (url.length > 10) {
val client = OkHttpClient.Builder()
.connectionPool(ConnectionPool(5, 60, TimeUnit.SECONDS))
.build()
// GET 요청 객체 생성
val builder: Request.Builder = Request.Builder().url(url).get()
val request: Request = builder.build()
// Blog.LOGE("telegram before request ")
// OkHttp 클라이언트로 GET 요청 객체 전송
val response: Response = client.newCall(request).execute()
if (response.isSuccessful) {
// 응답 받아서 처리
val body: ResponseBody? = response.body
if (body != null) {
var result = body.string()
var w = Gson().fromJson<CurrentWeather>(result,CurrentWeather::class.java)
var address =addresses.first()
w.addr = address.getAddressLine(0)
lastWeather.clear()
lastWeather.addAll(w.getSummaryInfo())
// Blog.LOGE("Location >>> ${result}\n${lastWeather}")
}
} else Blog.LOGE("telegram Error Occurred")
}
} catch (e: java.lang.Exception) {
e.printStackTrace()
}
}, 5, TimeUnit.SECONDS)
// }
}
}
@@ -174,9 +213,71 @@ class LocationUpdateService : Service(), LocationListener {
}
}
}
open class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
open class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
open class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
open class CurrentWeather {
var addr: String? = null
var current: Current? = null
var location : bums.lunatic.launcher.workers.Location? = null
// 아이콘 URL을 배열의 4번째(index 3) 요소로 추가 전달합니다.
fun getSummaryInfo(): List<String> {
val iconUrl = current?.condition?.icon?.let { "https:$it" } ?: ""
return listOf(
"${current?.condition?.text}", // 0: 날씨 상태
"${current?.temp_c}", // 1: 온도 및 습도
"${current?.humidity}",
"${addr}", // 2: 주소
iconUrl // 3: 아이콘 URL
)
}
}
@@ -93,11 +93,8 @@ object TaskAggregator {
// 병렬로 네트워크 요청 쏘기 (시간 획기적으로 단축됨)
val jobs = listOf(
async { RuliWebGetter(context).fetchData() },
async { TheQooGetter(context).fetchData() },
async { YoutubeGetter(context).fetchData() },
async { DCGetter(context).fetchData() },
async { FmKoreaGetter(context).fetchData() },
async { NewsFeedsGetter(context).fetchData() },
async { ClienGetter(context).fetchData() },
async { DotaxGetter(context).fetchData() },
@@ -7,6 +7,7 @@ import android.net.*
import android.os.*
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import bums.lunatic.launcher.utils.Blog
import com.frostwire.jlibtorrent.*
import com.frostwire.jlibtorrent.alerts.*
import com.frostwire.jlibtorrent.swig.error_code
@@ -15,7 +16,7 @@ import com.frostwire.jlibtorrent.swig.settings_pack
import com.frostwire.jlibtorrent.swig.torrent_status
import kotlinx.coroutines.*
import java.io.File
import android.telephony.TelephonyManager
data class TorrentTask(
val infoHash: String,
val name: String,
@@ -44,8 +45,7 @@ class TorrentService : Service() {
// 제어 플래그
private var isWifiConnected = false
private var isCharging = false
private lateinit var connectivityManager: ConnectivityManager
private var networkCallback: ConnectivityManager.NetworkCallback? = null
inner class TorrentBinder : Binder() {
fun getService(): TorrentService = this@TorrentService
@@ -55,6 +55,12 @@ class TorrentService : Service() {
override fun onCreate() {
super.onCreate()
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// if (!isKoreaRegion()) {
// Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
// stopForeground(true)
// stopSelf()
// return
// }
startForegroundService()
initLibTorrent()
@@ -63,10 +69,12 @@ class TorrentService : Service() {
// 2. 리시버 및 콜백 등록
registerBatteryReceiver()
registerNetworkCallback()
// 3. 초기 세션 상태 적용
updateSessionState()
startLightweightUpdater()
}
private fun checkInitialStatus() {
@@ -76,75 +84,218 @@ class TorrentService : Service() {
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
// Wi-Fi 상태
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
private fun registerBatteryReceiver() {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_POWER_CONNECTED)
addAction(Intent.ACTION_POWER_DISCONNECTED)
addAction(Intent.ACTION_BATTERY_CHANGED)
}
registerReceiver(batteryReceiver, filter)
}
var batteryPct = 50
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
isCharging = when (intent?.action) {
Intent.ACTION_POWER_CONNECTED -> true
Intent.ACTION_POWER_DISCONNECTED -> false
else -> isCharging
}
// isCharging = when (intent?.action) {
// Intent.ACTION_POWER_CONNECTED -> true
// Intent.ACTION_POWER_DISCONNECTED -> false
// else -> isCharging
// }
val intent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
batteryPct = (level / scale.toFloat() * 100).toInt()
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL || (batteryPct > 95)
var speedText = ""
var detailsText = ""
// if (isCharging) {
// speedText = when (plugged) {
// BatteryManager.BATTERY_PLUGGED_AC -> "고속"
// BatteryManager.BATTERY_PLUGGED_USB -> "저속"
// BatteryManager.BATTERY_PLUGGED_WIRELESS -> "무선"
// else -> ""
// }
//
// // 1. 전압(Voltage) 가져오기: 기본 단위는 밀리볼트(mV)
// val voltageMv = intent?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) ?: 0
// val voltageV = voltageMv / 1000.0f
//
// // 2. 전류(Current) 가져오기: BatteryManager를 통해 실시간 값 조회
// val batteryManager = getSystemService(BATTERY_SERVICE) as BatteryManager
// // 안드로이드 표준 단위는 마이크로암페어(µA) 입니다.
// val currentUa = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
//
// // 음수(방전)로 나올 수 있으므로 절대값 처리 후 암페어(A)로 변환
// val currentA = Math.abs(currentUa) / 1000000.0f
//
// // 3. 전력(Watt) 계산: W = V * A
// val wattage = voltageV * currentA
//
// // 값이 정상적으로 읽혔을 때만 텍스트 생성 (가끔 0으로 떨어지는 기기 방어)
// if (voltageV > 0f && currentA > 0f) {
// detailsText = String.format("%.1fV %.1fA (%.1fW)", voltageV, currentA, wattage)
// }
// }
// Blog.LOGE("detailsText >>> $detailsText")
updateSessionState()
}
}
private fun registerNetworkCallback() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
if (isWifiConnected != isWifi) {
isWifiConnected = isWifi
updateSessionState()
}
}
}
connectivityManager.registerNetworkCallback(request, networkCallback!!)
}
var lastUpdateTime = -1L
/**
* 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절
*/
private fun updateSessionState() {
if (isCharging) {
if (session.isPaused) session.resume()
// if (!isKoreaRegion()) {
// Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
// stopForeground(true)
// stopSelf()
// return
// }
// Wi-Fi면 5개, 셀룰러면 1개 다운로드 허용
val sp = SettingsPack()
if (isWifiConnected) {
sp.activeDownloads(5)
sp.activeLimit(8)
} else {
sp.activeDownloads(1)
sp.activeLimit(2)
checkIpAndStop()
if (session.isPaused) session.resume()
var curentTime = System.currentTimeMillis()
if (curentTime - lastUpdateTime > 5000) {
serviceScope.launch {
lastUpdateTime = curentTime
val vector = session.swig().get_torrents()
// (핸들, 계산된 우선순위 점수)
val torrentsWithMetadata = mutableListOf<kotlin.Pair<TorrentHandle, Int>>()
val torrentsWithoutMetadata = mutableListOf<TorrentHandle>()
val LOW_SPEED_THRESHOLD = 10 * 1024 // 10 KB/s 미만을 '속도 저조'로 판단
val PENALTY_SCORE = 1000 // 속도가 낮을 경우 부여할 페널티 (대기열 순서보다 큰 값)
for (i in 0 until vector.size.toInt()) {
val handle = TorrentHandle(vector.get(i))
if (!handle.isValid) continue
val status = handle.status()
if (!status.hasMetadata()) {
torrentsWithoutMetadata.add(handle)
} else if (!status.isFinished) {
val currentRate = status.downloadPayloadRate()
val basePriority = status.queuePosition()
// 속도가 너무 낮거나 0인 경우 페널티 부여
// 단, 막 시작해서 속도가 측정되지 않은 경우를 위해 추가 로직을 넣을 수 있지만
// 주기적 업데이트(2초)가 되므로 페널티를 받아도 다음 턴에 기회를 잡을 수 있습니다.
val finalScore = if (currentRate < LOW_SPEED_THRESHOLD) {
basePriority + PENALTY_SCORE
} else {
basePriority
}
torrentsWithMetadata.add(handle to finalScore)
}
}
// 1. 메타데이터 미수신: 무조건 유지
// 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬
if (isCharging && batteryPct > 75) {
torrentsWithoutMetadata.forEach { it.swig().resume() }
val maxSlots = if (isWifiConnected) 6 else 1
val sortedByPriority = torrentsWithMetadata.sortedBy { it.second }
sortedByPriority.forEachIndexed { index, pair ->
val handle = pair.first
if (index < maxSlots) {
handle.swig().resume()
} else {
handle.pause()
}
}
} else {
torrentsWithoutMetadata.forEach { it.swig().pause() }
// 배터리 모드
torrentsWithMetadata.forEach { it.first.pause() }
}
refreshTorrentStats()
}
}
}
private fun isKoreaRegion(): Boolean {
val tm = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
// 1. SIM 카드의 국가 코드 확인 (예: "kr")
val simCountry = tm.simCountryIso.lowercase()
// 2. 네트워크(기지국) 기준 국가 코드 확인
val networkCountry = tm.networkCountryIso.lowercase()
// SIM이나 네트워크 중 하나라도 'kr'이면 한국으로 판단
return simCountry == "kr" || networkCountry == "kr"
}
private fun checkIpAndStop() {
serviceScope.launch(Dispatchers.IO) {
try {
// 외부 API를 통해 국가 코드 확인 (예: ip-api.com)
val response = java.net.URL("http://ip-api.com/json/").readText()
if (!response.contains("\"countryCode\":\"KR\"")) {
withContext(Dispatchers.Main) {
Blog.LOGE("IP 위치가 대한민국이 아닙니다. 다운로드를 중지합니다.")
// 모든 토렌트 일시정지 또는 서비스 종료
session.pause()
stopSelf()
}
}
} catch (e: Exception) {
// 네트워크 오류 시 보수적으로 처리 (선택 사항)
}
}
}
private val TRACKER_URL = "https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt"
/**
* 세계 최신 트래커 리스트를 가져와 현재 모든 토렌트에 주입합니다.
*/
private fun updateTrackers() {
serviceScope.launch(Dispatchers.IO) {
try {
val trackers = java.net.URL(TRACKER_URL).readText()
.split("\n")
.filter { it.isNotBlank() }
val vector = session.swig().get_torrents()
for (i in 0 until vector.size.toInt()) {
val handle = TorrentHandle(vector.get(i))
if (!handle.isValid) continue
// 각 트래커를 토렌트에 추가
trackers.forEach { trackerUrl ->
handle.addTracker(AnnounceEntry(trackerUrl))
}
// 트래커 추가 후 즉시 재요청(Force Reannounce)
handle.forceReannounce()
}
println("TorrentService: ${trackers.size}개의 트래커 주입 완료")
} catch (e: Exception) {
e.printStackTrace()
}
session.applySettings(sp)
println("TorrentService: 충전 중 - 세션 활성화 (Wi-Fi: $isWifiConnected)")
} else {
if (!session.isPaused) session.pause()
// 💡 충전 중단 시 타이머 즉시 종료 (배터리 소모 0)
updateJob?.cancel()
println("TorrentService: 충전 중 아님 - 세션 및 업데이트 타이머 일시정지")
}
refreshTorrentStats() // 상태 변경 후 즉시 UI 갱신
}
private fun initLibTorrent() {
@@ -156,6 +307,18 @@ class TorrentService : Service() {
sp.setInteger(settings_pack.int_types.out_enc_policy.swigValue(), settings_pack.enc_policy.pe_enabled.swigValue())
sp.setBoolean(settings_pack.bool_types.enable_dht.swigValue(), true)
sp.setBoolean(settings_pack.bool_types.enable_lsd.swigValue(), true)
sp.setBoolean(settings_pack.bool_types.enable_upnp.swigValue(), true) // 공유기 포트포워딩 자동 설정
sp.setBoolean(settings_pack.bool_types.enable_natpmp.swigValue(), true)
sp.setInteger(settings_pack.int_types.connections_limit.swigValue(), 1000)
sp.setBoolean(settings_pack.bool_types.enable_incoming_utp.swigValue(), true)
sp.setBoolean(settings_pack.bool_types.enable_outgoing_utp.swigValue(), true)
sp.setInteger(settings_pack.int_types.dht_announce_interval.swigValue(), 300)
sp.connectionsLimit(1000)
sp.activeDownloads(10)
sp.activeLimit(10)
session.applySettings(sp)
@@ -175,7 +338,9 @@ class TorrentService : Service() {
moveToPrivateStorage((alert as TorrentFinishedAlert).handle())
refreshTorrentStats()
}
AlertType.METADATA_RECEIVED -> (alert as MetadataReceivedAlert).handle().saveResumeData()
AlertType.METADATA_RECEIVED -> {
(alert as MetadataReceivedAlert).handle().saveResumeData()
}
AlertType.SAVE_RESUME_DATA -> {
val ra = alert as SaveResumeDataAlert
saveResumeFile(ra.handle().infoHash().toString(), ra.params())
@@ -188,6 +353,7 @@ class TorrentService : Service() {
session.start()
restoreExistingDownloads()
updateTrackers()
}
private var updateJob: Job? = null
@@ -197,11 +363,16 @@ class TorrentService : Service() {
updateJob?.cancel()
updateJob = serviceScope.launch {
while (isActive) {
updateSessionState()
// 충전 중이고 세션이 돌아갈 때만 C++ 엔진에 상태 업데이트 요청
if (isCharging && !session.isPaused) {
var delayTime = 30000L
if (isCharging) {
session.postTorrentUpdates()
} else {
delayTime = 60000L
}
delay(2000) // 2초 주기 (원하는 대로 조절 가능)
delay(delayTime)
}
}
}
@@ -249,10 +420,11 @@ class TorrentService : Service() {
val isDownloading = (state.swig() == torrent_status.state_t.downloading.swigValue())
val stateText = when {
!isCharging -> "충전 대기 중"
isPaused -> "일시정지"
isDownloading -> "다운로드 중"
isFinished -> "완료"
!status.hasMetadata() -> "메타데이터 수신 중..."
!isCharging -> "충전 대기 중"
isDownloading -> "다운로드 중"
isPaused -> "일시정지"
else -> "대기 중"
}
@@ -272,21 +444,55 @@ class TorrentService : Service() {
}
private fun updateNotification(tasks: List<TorrentTask>) {
if (tasks.isEmpty()) return
val activeTask = tasks.firstOrNull { !it.isPaused && it.progress < 100f } ?: tasks.first()
val currentProgress = activeTask.progress.toInt()
if (tasks.isEmpty()) {
notificationManager.cancel(NOTIFICATION_ID)
return
}
// 최적화: 진행률이 변했을 때만 notify 호출
if (currentProgress == lastProgress) return
lastProgress = currentProgress
// 1. 메타데이터 수신 중인 파일 수
val metadataCount = tasks.count { it.stateText == "메타데이터 수신 중..." }
notificationBuilder.setContentTitle(if (tasks.size > 1) "${activeTask.name}${tasks.size - 1}" else activeTask.name)
.setContentText("${activeTask.stateText}: $currentProgress%")
.setProgress(100, currentProgress, false)
// 2. 실제 다운로드 중인 파일들 (충전/와이파이 조건으로 활성화된 것들)
val downloadingTasks = tasks.filter { it.isPaused == false }.filter { it.stateText == "다운로드 중" }
val downloadingCount = downloadingTasks.size
// 3. 다운로드 중인 파일들의 평균 진행률
val averageProgress = if (downloadingTasks.isNotEmpty()) {
downloadingTasks.map { it.progress }.average().toInt()
} else {
0
}
// 4. 전체 다운로드/업로드 속도 (Session에서 직접 가져옴)
val stats = session.stats()
val downloadSpeed = formatSpeed(stats.downloadRate())
val uploadSpeed = formatSpeed(stats.uploadRate())
// 알림 메시지 구성
val title = if (downloadingCount > 0) "${tasks.size} | 다운 ${downloadingCount} | 메타 ${metadataCount} | 대기 ${tasks.size - (downloadingCount + metadataCount)}" else "대기 중"
val content = StringBuilder().apply {
append("평균 다운 진행율 $averageProgress% | ")
append("$downloadSpeed$uploadSpeed")
}.toString()
notificationBuilder
.setContentTitle(title)
.setContentText(content)
.setProgress(100, averageProgress, downloadingCount == 0 && metadataCount > 0) // 메타 수신 중엔 불확정 게이지
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
}
// 속도 포맷팅 유틸리티
private fun formatSpeed(bytesPerSecond: Long): String {
val kb = bytesPerSecond / 1024.0
return if (kb >= 1024) {
String.format("%.1f MB/s", kb / 1024.0)
} else {
String.format("%.1f KB/s", kb)
}
}
// --- 데이터 관리 및 이동 로직 (기존 유지) ---
@RequiresApi(Build.VERSION_CODES.Q)
@@ -328,6 +534,10 @@ class TorrentService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.getStringExtra("EXTRA_MAGNET_URI")?.let { addMagnet(it) }
intent?.getBooleanExtra("WIFI_STATE", false)?.let {
isWifiConnected = it
}
return START_STICKY
}
@@ -337,9 +547,18 @@ class TorrentService : Service() {
val swigParams = libtorrent.parse_magnet_uri(magnetUri, error)
if (error.value() == 0) {
swigParams.setSave_path(tempDir.absolutePath)
swigParams.flags = swigParams.flags.or_(libtorrent.getAuto_managed())
// 수동 제어를 위해 auto_managed 해제
var flags = swigParams.flags
flags = flags.and_(libtorrent.getAuto_managed().inv())
swigParams.flags = flags
session.swig().async_add_torrent(swigParams)
// 추가 직후 상태 업데이트 호출하여 즉시 반영
updateSessionState()
}
updateTrackers()
} catch (e: Exception) { e.printStackTrace() }
}
@@ -362,8 +581,10 @@ class TorrentService : Service() {
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(batteryReceiver)
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
try {
unregisterReceiver(batteryReceiver)
} catch (e: Exception){e.printStackTrace()}
serviceScope.cancel()
}
@@ -421,5 +642,5 @@ class TorrentService : Service() {
e.printStackTrace()
}
}
}
@@ -34,6 +34,10 @@ import bums.lunatic.launcher.home.tokiz.ContentsPageInfo
import bums.lunatic.launcher.home.tokiz.HistoryItem
import bums.lunatic.launcher.home.tokiz.LastInfo
import bums.lunatic.launcher.home.tokiz.ReaderConfig
import bums.lunatic.launcher.model.ExpressionItem
import bums.lunatic.launcher.model.ExpressionWord
import bums.lunatic.launcher.model.QuoteItem
import bums.lunatic.launcher.model.WallContentGroup
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.JamoUtils
import bums.lunatic.launcher.utils.afterDay
@@ -76,10 +80,42 @@ object WorkersDb {
LastInfo::class, HistoryItem::class, ReaderConfig::class, ContentsCollection::class, ContentsPageInfo::class,
AppUsageLog::class,
WidgetData::class,
ExpressionItem::class,ExpressionWord::class,QuoteItem::class
)
//,UserActionModel::class
fun insertExpressions(groups: List<WallContentGroup>) {
getRealm().writeBlocking {
groups.forEach { group ->
group.translations.forEach { trans ->
// 1. 단어들을 RealmObject 리스트로 변환
val realmWords = trans.words.map { parsedWord ->
ExpressionWord().apply {
word = parsedWord.word
meaning = parsedWord.meaning
pronunciation = parsedWord.pron
}
}
// 2. ExpressionItem 생성 및 저장
copyToRealm(ExpressionItem().apply {
topic = group.topic
lang = trans.lang
mainText = trans.main
subText = trans.sub
pronunciation = trans.pron
// 💡 변환한 단어 리스트 넣기
words.addAll(realmWords)
timestamp = System.currentTimeMillis()
})
}
}
}
}
// [추가] 앱/연락처 사용 시 로그 저장 (기존 updateAppUse 대신 이거 호출)
fun logAppUsage(key: String, type: UsageLogType = UsageLogType.APP, datetime: UsageUpdateType) {
@@ -25,11 +25,11 @@ class YoutubeGetter(context: Context) : BaseGetter(context) {
@SuppressLint("RestrictedApi")
override fun realWork(): List<RealmObject> {
rssUrls.clear()
rssUrls.addAll(RssList.youtubeUrls)
// rssUrls.addAll(RssList.youtubeUrls)
RssDataType.YOUTUBE.isOn {
for (url in rssUrls) {
temp.addAll(ytChannel(Jsoup.connect(url).userAgent(USAGT).get()))
}
// for (url in rssUrls) {
// temp.addAll(ytChannel(Jsoup.connect(url).userAgent(USAGT).get()))
// }
}
return temp
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A1A">
<bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
android:id="@+id/pagedTextLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/layoutDocHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#CC000000"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvDocTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/btnChangeEncoding"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:src="@android:drawable/ic_menu_manage"
android:background="?attr/selectableItemBackgroundBorderless" />
</RelativeLayout>
<TextView
android:id="@+id/tvDocMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,54 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/photoView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ScrollView
android:id="@+id/layoutMetaInfo"
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_gravity="bottom"
android:background="#AA000000"
android:padding="16dp"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tvMetaTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFBB00"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvMetaData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:textColor="#FFFFFF"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
<ImageButton
android:id="@+id/btnToggleMeta"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="top|end"
android:layout_margin="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_info_details"
android:contentDescription="정보 보기" />
</FrameLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="#F5F5F5">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌐 ML Kit Translator"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="16dp"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp">
<TextView
android:id="@+id/tvInputLangStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="원본 (자동 감지)"
android:textColor="#666666"
android:textStyle="bold"
android:layout_centerVertical="true"/>
<TextView
android:id="@+id/tvTtsInput"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:gravity="center"
android:text="volume_up"
android:textSize="24sp"
android:fontFamily="@font/material_symbols"
android:textColor="#2196F3"
android:background="?attr/selectableItemBackgroundBorderless"
android:visibility="gone"/>
</RelativeLayout>
<EditText
android:id="@+id/etInputText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@android:color/white"
android:gravity="top|start"
android:hint="번역할 내용을 입력하세요."
android:padding="12dp"
android:inputType="textMultiLine"
android:elevation="2dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="#E0E0E0"
android:padding="8dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvSwap"
android:layout_width="48dp"
android:layout_height="48dp"
android:gravity="center"
android:text="swap_vert"
android:textSize="28sp"
android:fontFamily="@font/material_symbols"
android:textColor="#444444"
android:background="?attr/selectableItemBackgroundBorderless"/>
<Spinner
android:id="@+id/spinnerTargetLang"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/tvSwap"
android:layout_centerVertical="true"
android:layout_marginStart="8dp"/>
<Button
android:id="@+id/btnTranslate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:text="번역"/>
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TTS 속도"
android:textSize="12sp"
android:layout_marginEnd="8dp"/>
<SeekBar
android:id="@+id/sbTtsSpeed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="200"
android:progress="100"/> </LinearLayout>
</LinearLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="번역 결과"
android:textColor="#666666"
android:textStyle="bold"
android:layout_centerVertical="true"/>
<TextView
android:id="@+id/tvTtsOutput"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:gravity="center"
android:text="volume_up"
android:textSize="24sp"
android:fontFamily="@font/material_symbols"
android:textColor="#2196F3"
android:background="?attr/selectableItemBackgroundBorderless"
android:visibility="gone"/>
</RelativeLayout>
<TextView
android:id="@+id/tvOutputText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@android:color/white"
android:padding="12dp"
android:textIsSelectable="true"
android:textSize="16sp"
android:elevation="2dp"
android:hint="번역 결과가 표시됩니다."/>
<LinearLayout
android:id="@+id/layoutProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="gone"
android:layout_marginTop="8dp">
<ProgressBar android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="8dp" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" text="모델 다운로드 중..." textSize="12sp" />
</LinearLayout>
</LinearLayout>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -131,6 +131,12 @@
android:background="@color/black"
android:gravity="center_vertical"
>
<TextView
app:autoSizeTextType="uniform"
style="@style/SearchAccs"
android:id="@+id/search_reader"
android:text="READER"
/>
<androidx.appcompat.widget.AppCompatSpinner
style="@style/SearchAccs"
android:layout_marginLeft="6dp"
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/tvDialogProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="0% (1 / 100)"
android:textColor="#000000"
android:textSize="18sp"
android:textStyle="bold" />
<SeekBar
android:id="@+id/seekBarPage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:max="100" />
</LinearLayout>
@@ -147,6 +147,15 @@
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
<TextView
android:id="@+id/btnCopySelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="외부폴더로 복사"
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
<TextView
android:id="@+id/btnMoveSelected"
android:layout_width="0dp"
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#DD121212"
android:padding="16dp">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<LinearLayout
android:id="@+id/layout_languages"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="8dp" />
</HorizontalScrollView>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<LinearLayout
android:id="@+id/layout_topics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp" />
</HorizontalScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_expressions"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="80dp" /> </LinearLayout>
+16 -1
View File
@@ -5,6 +5,21 @@
android:orientation="vertical"
android:padding="4dp"
android:background="?attr/selectableItemBackground">
<ImageView android:id="@+id/ivThumb" android:layout_width="match_parent" android:layout_height="100dp" android:scaleType="centerCrop" android:background="#E0E0E0"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="100dp">
<ImageView
android:id="@+id/ivThumb"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<TextureView
android:id="@+id/previewTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="end" android:gravity="center" android:layout_marginTop="4dp"/>
</LinearLayout>
@@ -6,7 +6,23 @@
android:padding="8dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<ImageView android:id="@+id/ivThumb" android:layout_width="60dp" android:layout_height="60dp" android:scaleType="centerCrop" android:background="#E0E0E0"/>
<FrameLayout
android:layout_width="60dp"
android:layout_height="60dp">
<ImageView
android:id="@+id/ivThumb"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<TextureView
android:id="@+id/previewTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:layout_marginStart="12dp">
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="middle"/>
<TextView android:id="@+id/tvFileSize" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/darker_gray" android:layout_marginTop="4dp"/>
@@ -11,7 +11,7 @@
android:layout_height="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="#CC000000"
android:background="#D000"
app:layout_constraintTop_toTopOf="parent">
<TextView android:id="@+id/btn_home" android:text="home" style="@style/MaterialIconButtonStyle" />
@@ -36,13 +36,13 @@
</LinearLayout>
<TextView
android:id="@+id/current_chapter"
android:layout_marginLeft="30dp"
android:layout_marginLeft="10dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:layout_marginRight="30dp"
android:layout_marginRight="10dp"
android:id="@+id/current_page"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
+19 -15
View File
@@ -50,18 +50,18 @@
app:fab_label="🎨"
style="@style/CommonFabStyle"
android:id="@+id/webtoons"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="🗯️"
style="@style/CommonFabStyle"
android:id="@+id/comics"/>
<!-- <bums.lunatic.launcher.view.FloatingActionButton-->
<!-- app:fab_label="🗯️"-->
<!-- style="@style/CommonFabStyle"-->
<!-- android:id="@+id/comics"/>-->
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="📺"
style="@style/CommonFabStyle"
android:id="@+id/youtube"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="🤖"
style="@style/CommonFabStyle"
android:id="@+id/perplexity"/>
<!-- <bums.lunatic.launcher.view.FloatingActionButton-->
<!-- app:fab_label="🤖"-->
<!-- style="@style/CommonFabStyle"-->
<!-- android:id="@+id/perplexity"/>-->
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="😂"
android:visibility="gone"
@@ -93,7 +93,6 @@
/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="📦"
android:visibility="gone"
style="@style/CommonFabStyle"
android:id="@+id/btn_completed_files"
/>
@@ -120,12 +119,17 @@
style="@style/CommonFabStyle"
android:id="@+id/btn_img3"
/>
<!-- <bums.lunatic.launcher.view.FloatingActionButton-->
<!-- app:fab_label="⛏️"-->
<!-- android:visibility="gone"-->
<!-- style="@style/CommonFabStyle"-->
<!-- android:id="@+id/btn_img4"-->
<!-- />-->
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="🌐"
style="@style/CommonFabStyle"
android:id="@+id/btn_img4"
/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="🌍"
style="@style/CommonFabStyle"
android:id="@+id/btn_learn" />
<bums.lunatic.launcher.view.FloatingActionButton
style="@style/CommonFabStyle"
app:fab_label="❌"
@@ -134,6 +134,27 @@
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/geminiInputLayout"
android:layout_width="@dimen/threeTwentyFour"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/twelve"
android:hint="@string/owm_key"
app:endIconMode="clear_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/owmInputLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputgemini"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- <com.google.android.material.textview.MaterialTextView-->
<!-- android:id="@+id/doubleTapLock"-->
<!-- android:layout_width="wrap_content"-->
+9
View File
@@ -17,6 +17,15 @@
<item name="scrimBackground">@color/almost_transparent</item>
</style>
<style name="Theme.Player" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
<!-- Background -->
<item name="android:windowShowWallpaper">false</item>
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="roundedImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">10dp</item>
@@ -4,6 +4,7 @@
<uses-feature android:name="android.hardware.type.watch" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<application
android:allowBackup="true"
@@ -27,6 +28,8 @@
android:name="android.support.wearable.complications.UPDATE_PERIOD_SECONDS"
android:value="0" />
</service>
<receiver android:name=".tile.TileActionReceiver" android:exported="false" />
<service
android:name=".tile.MainTileService"
android:exported="true"
@@ -14,6 +14,9 @@ import androidx.activity.compose.setContent
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
@@ -22,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.TimeText
@@ -40,7 +44,9 @@ class MainActivity : ComponentActivity() , SensorEventListener {
setTheme(android.R.style.Theme_DeviceDefault)
setContent {
WearApp("Android")
WearApp("Android",sendGestureToLauncher = { text ->
sendGestureToLauncher(text)
})
}
}
@@ -49,17 +55,17 @@ class MainActivity : ComponentActivity() , SensorEventListener {
}
private lateinit var messageClient: MessageClient
private var lastX = 0f
override fun onSensorChanged(p0: SensorEvent?) {
p0?.let { event ->
val x = event.values[0]
// 아주 단순한 Threshold 예시 (나중에 DTW로 교체)
if (x > 15f) { // 오른쪽으로 강하게 휘둘렀을 때
sendGestureToLauncher("/gesture/next")
} else if (x < -15f) {
sendGestureToLauncher("/gesture/prev")
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
if (it.sensor.type == Sensor.TYPE_ACCELEROMETER) {
val x = it.values[0]
// 임계값(Threshold)을 약간 높여 오작동 방지
if (x > 20f) {
sendGestureToLauncher("/gesture/next")
} else if (x < -20f) {
sendGestureToLauncher("/gesture/prev")
}
}
lastX = x
}
}
private fun sendGestureToLauncher(path: String) {
@@ -73,7 +79,7 @@ class MainActivity : ComponentActivity() , SensorEventListener {
}
@Composable
fun WearApp(greetingName: String) {
fun WearApp(greetingName: String,sendGestureToLauncher : (String) -> Unit = {}) {
LunarLauncherTheme {
Box(
modifier = Modifier
@@ -82,19 +88,49 @@ fun WearApp(greetingName: String) {
contentAlignment = Alignment.Center
) {
TimeText()
Greeting(greetingName = greetingName)
Greeting(greetingName = greetingName,
sendGestureToLauncher = {text ->
sendGestureToLauncher(text)
})
}
}
}
@Composable
fun Greeting(greetingName: String) {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary,
text = stringResource(R.string.hello_world, greetingName)
)
fun Greeting(greetingName: String, sendGestureToLauncher : (String) -> Unit = {}) {
// Text(
// modifier = Modifier.fillMaxWidth(),
// textAlign = TextAlign.Center,
// color = MaterialTheme.colors.primary,
// text = stringResource(R.string.hello_world, greetingName)
// )
Column(Modifier.fillMaxWidth().fillMaxHeight()) {
Row(Modifier.fillMaxWidth().weight(0.5f)) {
Button(modifier = Modifier.weight(0.5f).fillMaxHeight(), onClick = {
sendGestureToLauncher("/gesture/next")
}) {
Text("B1")
}
Button(modifier = Modifier.weight(0.5f).fillMaxHeight(), onClick = {
sendGestureToLauncher("/gesture/prev")
}) {
Text("B2")
}
}
Row(Modifier.fillMaxWidth().weight(0.5f)) {
Button(modifier = Modifier.weight(0.5f).fillMaxHeight(), onClick = {
sendGestureToLauncher("/gesture/back")
}) {
Text("B3")
}
Button(modifier = Modifier.weight(0.5f).fillMaxHeight(), onClick = {
sendGestureToLauncher("/gesture/refresh")
}) {
Text("B4")
}
}
}
}
@Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true)
@@ -1,8 +1,15 @@
package bums.lunatic.launcher.tile
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders.argb
import androidx.wear.protolayout.DimensionBuilders.dp
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.ModifiersBuilders
import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.protolayout.TimelineBuilders
import androidx.wear.protolayout.material.Colors
@@ -14,6 +21,7 @@ import androidx.wear.tiles.TileBuilders
import androidx.wear.tiles.tooling.preview.Preview
import androidx.wear.tiles.tooling.preview.TilePreviewData
import androidx.wear.tooling.preview.devices.WearDevices
import com.google.android.gms.wearable.Wearable
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.tiles.SuspendingTileService
@@ -31,7 +39,39 @@ class MainTileService : SuspendingTileService() {
override suspend fun tileRequest(
requestParams: RequestBuilders.TileRequest
) = tile(requestParams, this)
): TileBuilders.Tile {
// 💡 사용자가 방금 누른 버튼의 ID(path)를 가져옵니다.
val clickedPath = requestParams.currentState.lastClickableId
// 눌린 버튼이 있다면 폰(런처)으로 메시지 전송!
if (clickedPath.isNotEmpty()) {
Log.d("MainTileService", "Clicked path: $clickedPath")
Wearable.getNodeClient(this).connectedNodes.addOnSuccessListener { nodes ->
for (node in nodes) {
Wearable.getMessageClient(this).sendMessage(node.id, clickedPath, null)
}
}
}
// 타일 UI 렌더링
val singleTileTimeline = TimelineBuilders.Timeline.Builder()
.addTimelineEntry(
TimelineBuilders.TimelineEntry.Builder()
.setLayout(
LayoutElementBuilders.Layout.Builder()
.setRoot(tileLayout(requestParams, this))
.build()
)
.build()
)
.build()
return TileBuilders.Tile.Builder()
.setResourcesVersion(RESOURCES_VERSION)
.setTileTimeline(singleTileTimeline)
.build()
}
}
private fun resources(
@@ -64,22 +104,76 @@ private fun tile(
.build()
}
private fun tileLayout(
requestParams: RequestBuilders.TileRequest,
context: Context,
): LayoutElementBuilders.LayoutElement {
return PrimaryLayout.Builder(requestParams.deviceConfiguration)
.setResponsiveContentInsetEnabled(true)
.setContent(
Text.Builder(context, "Hello World!")
.setColor(argb(Colors.DEFAULT.onSurface))
.setTypography(Typography.TYPOGRAPHY_CAPTION1)
.build()
).build()
private fun tileLayout(requestParams: RequestBuilders.TileRequest, context: Context): LayoutElementBuilders.LayoutElement {
// 버튼 생성 함수 (크기를 48dp로 살짝 줄여서 화면에 쏙 들어가게 맞춤)
fun createNavButton(label: String, path: String): LayoutElementBuilders.LayoutElement {
return LayoutElementBuilders.Box.Builder()
.setModifiers(ModifiersBuilders.Modifiers.Builder()
.setClickable(ModifiersBuilders.Clickable.Builder()
.setId(path)
.setOnClick(ActionBuilders.LoadAction.Builder().build())
.build())
.setBackground(ModifiersBuilders.Background.Builder()
.setColor(argb(0xFF303030.toInt()))
.setCorner(ModifiersBuilders.Corner.Builder().setRadius(dp(24f)).build()) // 원형에 가깝게
.build())
.build())
.setWidth(dp(48f)).setHeight(dp(48f))
.addContent(
Text.Builder(context, label)
.setTypography(Typography.TYPOGRAPHY_CAPTION2) // 글자 크기 최적화
.setColor(argb(0xFFFFFFFF.toInt()))
.build()
)
.build()
}
// 1층: 제일 위에 UP 버튼 하나만
val topRow = LayoutElementBuilders.Row.Builder()
.addContent(createNavButton("UP", "/gesture/up"))
.build()
// 2층: 중간에 LEFT, 빈 공간, RIGHT 배치
val middleRow = LayoutElementBuilders.Row.Builder()
.addContent(createNavButton("LEFT", "/gesture/left"))
// 좌우 버튼 사이의 널찍한 빈 공간 (UP/DOWN 버튼이 들어갈 간격)
.addContent(LayoutElementBuilders.Spacer.Builder().setWidth(dp(56f)).build())
.addContent(createNavButton("RIGHT", "/gesture/right"))
.build()
// 3층: 제일 아래에 DOWN 버튼 하나만
val bottomRow = LayoutElementBuilders.Row.Builder()
.addContent(createNavButton("DOWN", "/gesture/down"))
.build()
// 전체를 세로로 정렬하여 합체
return LayoutElementBuilders.Column.Builder()
.setHorizontalAlignment(LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER) // 가운데 정렬 필수
.addContent(topRow)
.addContent(LayoutElementBuilders.Spacer.Builder().setHeight(dp(8f)).build()) // 1~2층 간격
.addContent(middleRow)
.addContent(LayoutElementBuilders.Spacer.Builder().setHeight(dp(8f)).build()) // 2~3층 간격
.addContent(bottomRow)
.build()
}
@Preview(device = WearDevices.SMALL_ROUND)
@Preview(device = WearDevices.LARGE_ROUND)
fun tilePreview(context: Context) = TilePreviewData(::resources) {
tile(it, context)
}
class TileActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val path = intent.getStringExtra("path") ?: return
// 폰(런처)으로 메시지 전송
Wearable.getNodeClient(context).connectedNodes.addOnSuccessListener { nodes ->
for (node in nodes) {
Wearable.getMessageClient(context).sendMessage(node.id, path, null)
}
}
}
}