Compare commits
42
Commits
416279a11f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73a1dda56 | ||
|
|
2177452f52 | ||
|
|
b526fe4670 | ||
|
|
1f696902da | ||
|
|
14e26702c1 | ||
|
|
ad505863b7 | ||
|
|
24a2a74768 | ||
|
|
278c1b4510 | ||
|
|
dab91b1a65 | ||
|
|
0400385cf8 | ||
|
|
378f6495be | ||
|
|
257ddb6776 | ||
|
|
c5d0a827e9 | ||
|
|
b64df76c90 | ||
|
|
ac1c0b12b3 | ||
|
|
fd0ee96c04 | ||
|
|
c8000d022c | ||
|
|
5b86fc7e2b | ||
|
|
6813a6bdd7 | ||
|
|
5ded732345 | ||
|
|
fa5e6bbedc | ||
|
|
d0422fafeb | ||
|
|
2327326cca | ||
|
|
162d2b7563 | ||
|
|
964655af7f | ||
|
|
0405629106 | ||
|
|
f9e8c3de37 | ||
|
|
1da8d5bb62 | ||
|
|
5e7c121cf8 | ||
|
|
274eaa3213 | ||
|
|
80c06c5ab3 | ||
|
|
d7647f57e8 | ||
|
|
ba869e8eb4 | ||
|
|
8c3bc96417 | ||
|
|
dc19ff5339 | ||
|
|
e9e585bad0 | ||
|
|
9526104a4a | ||
|
|
92d228d2e0 | ||
|
|
5a8e2293ca | ||
|
|
2ed5957365 | ||
|
|
86f387b2fa | ||
|
|
57039939fd |
+10
-1
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Vendored
+13
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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_;
|
||||
}
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"-->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user