This commit is contained in:
lunaticbum 2026-03-24 15:53:29 +09:00
parent 112012ce0c
commit 68c16339ea
8 changed files with 298 additions and 60 deletions

View File

@ -81,6 +81,19 @@ port.onMessage.addListener(response => {
}
}
} break;
case "PAUSE_YT": // 5초 뒤로
{
if (document.location.href.search("youtube") > -1) {
let btn =
document.querySelector('button[aria-label="동영상 일시중지"]') ;
if (btn) {
btn.click();
return;
}
}
} break;
case "SEEK_PREV": // 5초 뒤로
{
@ -594,6 +607,36 @@ var mainContentsEl = null;
let lastState = null; // 이전 상태 저장 (-1, 0, 1)
let throttleTimer = null;
function sendCookiesToNative() {
try {
const cookies = document.cookie;
// 쿠키가 존재할 때만 전송
if (cookies && cookies.length > 0) {
const netscapeCookies = '# Netscape HTTP Cookie File\\n' +
document.cookie.split('; ')
.map(c => {
const eqIdx = c.indexOf('=');
const name = c.substring(0, eqIdx).trim();
const value = decodeURIComponent(c.substring(eqIdx + 1));
return `.youtube.com\\tTRUE\\t/\\tFALSE\\t0\\t${name}\\t${value}`;
})
.filter(c => c.includes('youtube') || c.includes('google'))
.join('\\n');
sendMessage({
type: "COOKIES_REPORT",
value: netscapeCookies,
url: location.href
});
console.log("Cookies sent to native.");
}
} catch (e) {
// 예외 무시
}
}
document.addEventListener('DOMContentLoaded', function () {
const currentUrl = location.href;
@ -650,6 +693,13 @@ document.addEventListener('DOMContentLoaded', function () {
throttleTimer = null;
}, 150); // 0.15초 간격으로 체크 (사용성에 따라 조절 가능)
};
if (document.readyState === 'complete') {
sendCookiesToNative();
} else {
// 2. 아직 로딩 중이라면 window.onload(모든 리소스 로드 완료) 시점에 실행
window.addEventListener('load', sendCookiesToNative);
}
})
const keywords = ["youtube", "mojeek"];

View File

@ -6,6 +6,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.app.WallpaperManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
@ -47,14 +48,66 @@ import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
import android.bluetooth.BluetoothClass
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.media.AudioManager
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.KeyEvent
import androidx.annotation.RequiresPermission
import androidx.core.content.ContentProviderCompat.requireContext
import androidx.work.workDataOf
import bums.lunatic.launcher.home.GeckoWeb.Companion.currentCookieString
import bums.lunatic.launcher.home.GeckoWeb.Companion.currentCookieUrlString
import kotlinx.coroutines.delay
class WallpaperAutoChangeWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val folderPath = inputData.getString("FOLDER_PATH") ?: return Result.failure()
val folder = File(folderPath)
// 1. 이미지 파일 목록 가져오기
val images = folder.listFiles { file ->
file.isFile && (file.extension.equals("jpg", true) || file.extension.equals("png", true) || file.extension.equals("jpeg", true))
}
if (images.isNullOrEmpty()) return Result.failure()
// 2. 랜덤 이미지 선택 및 비트맵 로드
val randomImage = images.random()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = false }
val originalBitmap = BitmapFactory.decodeFile(randomImage.absolutePath, options) ?: return Result.failure()
return try {
val wm = WallpaperManager.getInstance(context)
val targetWidth = wm.desiredMinimumWidth
val targetHeight = wm.desiredMinimumHeight
// 3. 비율 유지하며 Center Crop 처리
val finalBitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(finalBitmap)
val scale = Math.max(targetWidth.toFloat() / originalBitmap.width, targetHeight.toFloat() / originalBitmap.height)
val scaledWidth = scale * originalBitmap.width
val scaledHeight = scale * originalBitmap.height
val left = (targetWidth - scaledWidth) / 2f
val top = (targetHeight - scaledHeight) / 2f
canvas.drawBitmap(originalBitmap, null, RectF(left, top, left + scaledWidth, top + scaledHeight), Paint(Paint.FILTER_BITMAP_FLAG))
// 4. 적용
wm.setBitmap(finalBitmap, null, true, WallpaperManager.FLAG_SYSTEM)
Result.success()
} catch (e: Exception) {
e.printStackTrace()
Result.retry()
}
}
}
class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// 통합 시스템 작업 실행
@ -95,7 +148,7 @@ class ForeGroundService : Service() {
val ACTION_SIT_DOWN = "ACTION_SIT_DOWN"
val ACTION_COPY_COMPLETE = "ACTION_COPY_COMPLETE"
val targetUrls = arrayListOf<String>()
val targetUrls = arrayListOf<Pair<String, Boolean>>()
}
enum class BLUETOOTH_STATE(val statestr: String) {
@ -106,7 +159,7 @@ class ForeGroundService : Service() {
var blueToothAdapter:BluetoothAdapter? = null
private var mWorkManager: WorkManager? = null
private val serviceScope = CoroutineScope(Dispatchers.Default)
val NOTIF_ID = 830721
override fun onCreate() {
super.onCreate()
@ -114,9 +167,31 @@ class ForeGroundService : Service() {
val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)
registerReceiver(bluetoothreceiver, filter)
refreshFeeds()
startWallpaperTimer()
startForeGround(vibrator = true)
}
private fun startWallpaperTimer() {
serviceScope.launch {
while (true) {
// 실행하고자 하는 폴더 경로 (본인 경로로 수정)
val myFolderPath = File(File(getExternalFilesDir(null), "completed_torrents"),"이미지").absolutePath
val workRequest = OneTimeWorkRequestBuilder<WallpaperAutoChangeWorker>()
.setInputData(workDataOf("FOLDER_PATH" to myFolderPath))
.build()
workmanager()?.enqueueUniqueWork(
"SingleWallpaperChange",
ExistingWorkPolicy.REPLACE,
workRequest
)
delay(TimeUnit.MINUTES.toMillis(10)) // 10분 대기
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Blog.LOGE("intent?.action >> ${intent?.action}")
when(intent?.action) {
@ -128,7 +203,7 @@ class ForeGroundService : Service() {
ACTION_VIDEO_DOWNLOAD -> {
intent?.getStringExtra(EXTRA_TARGET_URL)?.let {
Uri.parse(it)?.let {
addToTargetYtubeUrl(it.toString())
addToTargetYtubeUrl(it.toString(), intent.getBooleanExtra("forMusic", false))
}
}
}
@ -159,51 +234,76 @@ class ForeGroundService : Service() {
return START_STICKY
}
fun addToTargetYtubeUrl(url : String) {
targetUrls.add(url)
if((targetUrls?.size ?: 0) > 0) {
downloadVideo(targetUrls?.firstOrNull())
fun addToTargetYtubeUrl(url : String? = null, forMusic: Boolean = false) {
url?.let { url ->
if (url.length > 0) {
targetUrls.add(url to forMusic)
}
}
targetUrls.removeFirstOrNull()?.let {
downloadVideo(it.first,it.second)
}
}
var currentProcessId : String? = null
set(value) {
field = value
if (value == null) {
startForeGround(max= 0, progress = 0, vibrator = false)
// startForeGround(max= 0, progress = 0, vibrator = false)
}
}
fun downloadVideo(url: String?) {
fun downloadVideo(url: String?, forMusic: Boolean = false) {
url?.let {
CoroutineScope(Dispatchers.IO).launch {
try {
// val youtubeDLDir = File(
// Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
// "youtubedl-android"
// )
val youtubeDLDir = File(getExternalFilesDir(null), "completed_torrents")
val command = YoutubeDLRequest(url)
command.addOption("-o", youtubeDLDir.getAbsolutePath() + "/%(title)s.%(ext)s");
val command = YoutubeDLRequest(url).apply {
addOption("-q") // 로그 최소화
addOption("--no-warnings") // 경고 숨김
val cookieFile = File(this@ForeGroundService.cacheDir, "cookies.txt")
addOption("--cookies", "${cookieFile.absolutePath}")
// 출력 경로
addOption("-o", "${youtubeDLDir.absolutePath}/%(title)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 {
// 일반 영상용
addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
}
// 안정성 향상 옵션 (모두에 적용)
addOption("--no-mtime") // 파일 수정시간 안 건드림
addOption("--restrict-filenames") // 특수문자 제한
}
currentProcessId = UUID.randomUUID().toString()
YoutubeDL.getInstance()
.execute(command, currentProcessId) { progress, est, str ->
startForeGround(100, progress.toInt(),str, false)
startForeGround(100, progress.toInt(), str, false)
if (progress >= 100) {
targetUrls.remove(url)
currentProcessId = null
if((targetUrls?.size ?: 0) > 0) {
downloadVideo(targetUrls?.firstOrNull())
if ((targetUrls?.size ?: 0) > 0) {
addToTargetYtubeUrl()
}
}
}
} catch (e: Exception) {
e.printStackTrace()
Blog.LOGE("Download Error", e)
currentProcessId = null
if((targetUrls?.size ?: 0) > 0) {
downloadVideo(targetUrls?.firstOrNull())
if ((targetUrls?.size ?: 0) > 0) {
addToTargetYtubeUrl()
}
}
}

View File

@ -737,11 +737,11 @@ class CompletedFilesFragment : Fragment() {
private fun organizeRootFiles() {
CoroutineScope(Dispatchers.IO).launch {
// 1. 루트 폴더의 파일 목록 가져오기
val filesInRoot = rootDir.listFiles()?.filter { it.isFile } ?: emptyList()
val filesInRoot = if (selectedFiles.isEmpty()) rootDir.listFiles()?.filter { it.isFile } else selectedFiles
var movedCount = 0
// 2. 널브러진 파일들을 확장자별 폴더로 이동
filesInRoot.forEach { file ->
filesInRoot?.forEach { file ->
val ext = file.extension.lowercase()
val folderName = when {
extImages.contains(ext) -> "이미지"

View File

@ -83,6 +83,7 @@ open class GeckoWeb @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : GeckoView(context, attrs) {
// --- 1. Properties & Initialization ---
// 1. 세션 상태를 저장할 SharedPreferences 키
@ -193,6 +194,8 @@ open class GeckoWeb @JvmOverloads constructor(
companion object {
var currentRetryCount = 0
var currentCookieString = ""
var currentCookieUrlString = ""
}
@ -201,7 +204,7 @@ open class GeckoWeb @JvmOverloads constructor(
override fun setVisibility(visibility: Int) {
super.setVisibility(visibility)
decoViews.filter { it.id > -1 && it.id != R.id.btn_dl_video }.forEach { it.visibility = visibility }
decoViews.filter { it.id > -1 }.forEach { it.visibility = visibility }
}
open fun loadUrl(url: String, param: String? = null) {
@ -226,58 +229,110 @@ open class GeckoWeb @JvmOverloads constructor(
currentRetryCount = 0
}
fun checkIfDownloadable(url: String) {
// UI 초기화
post {
var lastCheckUrlS = hashSetOf<String>()
fun cleanYoutubeUrl(url: String): String {
return try {
val uri = Uri.parse(url)
val videoId = uri.getQueryParameter("v")
if (videoId != null) {
// v=만 남기고 나머지 파라미터 제거
"https://www.youtube.com/watch?v=$videoId"
} else {
// Shorts나 다른 형식
url
}
} catch (e: Exception) {
url // 실패시 원본 반환
}
}
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.IO).launch {
try {
val request = YoutubeDLRequest(url)
// mGKCookie?.COOKIES?.let { cookieStr ->
// val cookieFile = File(context.filesDir, "cookies.txt")
// val cookies = cookieStr.split(";").mapNotNull {
// val p = it.trim().split("=", limit = 2)
// if (p.size == 2) p[0] to p[1] else null
// }.toMap()
// val expires = (System.currentTimeMillis() / 1000) + 3600 * 24 * 7
//
// val content = buildString {
// appendLine("# Netscape HTTP Cookie File")
// cookies.forEach { (k, v) ->
// appendLine(".${url.toUri().host}\tTRUE\t/\tTRUE\t$expires\t$k\t$v")
// }
// }
// cookieFile.writeText(content)
// request.addOption("--cookies", cookieFile.absolutePath)
// }
val request = YoutubeDLRequest(cleanUrl).apply {
// 1. yt-dlp 업데이트 강제 (가장 중요!)
addOption("-q") // 로그 최소화
addOption("--no-warnings") // 경고 숨김
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 {
// 일반 영상용 기본 포맷
addOption("-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best")
}
// 디버깅용 (테스트 후 제거)
// addOption("--verbose")
}
val videoInfo = YoutubeDL.getInstance().getInfo(request)
if (videoInfo != null && !videoInfo.title.isNullOrEmpty()) {
post {
CoroutineScope(Dispatchers.Main).launch {
decoViews.firstOrNull { it.id == R.id.btn_dl_video }?.let { view ->
view.setOnClickListener { videoDownload(url) }
view.setOnClickListener { videoDownload(cleanUrl, forMusic) }
view.visibility = VISIBLE
}
}
}
} catch (e: Exception) {
// Log.e("GeckoWeb", "Download Check Error", e)
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)
// }
// }
// }
//
// }
// }
}
}
}
fun videoDownload(videoUrl: String) {
fun videoDownload(videoUrl: String,forMusic : Boolean = false) {
val intent = Intent(context, ForeGroundService::class.java).apply {
action = ACTION_VIDEO_DOWNLOAD
putExtra(EXTRA_TARGET_URL, videoUrl)
putExtra("forMusic",forMusic)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent)
else context.startService(intent)
pauseYT()
}
// --- 3. Delegates (선언 위치를 위로 올려 초기화 보장) ---
@ -322,6 +377,8 @@ open class GeckoWeb @JvmOverloads constructor(
// [Navigation Delegate]
private val navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
Uri.parse(uri)?.let {
if (it.host?.let { h -> lastedUrl?.contains(h, true) } == true ||
@ -378,6 +435,7 @@ open class GeckoWeb @JvmOverloads constructor(
}
override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
Blog.LOGE("onCanGoBack $canGoBack ${session}")
this@GeckoWeb.canGoBack = canGoBack
}
}
@ -411,17 +469,21 @@ 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}")
}
override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) {
lastSessionState = sessionState
onSessionStateChangeCallback?.invoke(sessionState)
Blog.LOGE("onSessionStateChange $sessionState ${session}")
}
}
private var lastSessionState: GeckoSession.SessionState? = null
@ -557,9 +619,28 @@ open class GeckoWeb @JvmOverloads constructor(
return false // 실제 구현 시에는 이전 MTRANS_Y 값과 비교 로직 추가
}
private suspend fun saveYoutubeCookiesToFile(): String? {
val cookiesStr = currentCookieString
return try {
val cookieFile = File(context.cacheDir, "cookies.txt")
cookieFile.writeText(cookiesStr)
cookieFile.absolutePath
} catch (e: Exception) {
Blog.LOGE("Cookie file save failed", e)
null
}
}
private fun handlePortMessage(msg: PortMessage) {
when (msg.type) {
"COOKIES_REPORT"-> {
Blog.LOGE("${msg.value} -> ${msg.url}")
currentCookieString = msg.value ?: ""
currentCookieUrlString = msg.url ?: ""
CoroutineScope(Dispatchers.IO).launch {
saveYoutubeCookiesToFile()
}
}
"SCROLL_STATE" -> {
Blog.LOGE("${msg.type} : ${msg.value}")
scrollState = msg.value?.toInt() ?: 0
@ -790,4 +871,9 @@ open class GeckoWeb @JvmOverloads constructor(
fun play_pause() {
sendJsonMsg("PLAY_PAUSE")
}
fun pauseYT() {
sendJsonMsg("PAUSE_YT")
}
}

View File

@ -426,6 +426,7 @@ open class NeoRssActivity : CommonActivity() {
FFmpeg.getInstance().init(this)
CoroutineScope(Dispatchers.IO).launch {
try {
Blog.LOGE("YoutubeDL.getInstance().updateYoutubeDL()")
YoutubeDL.getInstance().updateYoutubeDL(this@NeoRssActivity)
} catch (e: YoutubeDLException) {
Blog.LOGE("failed to initialize youtubedl-android", e)

View File

@ -104,6 +104,7 @@ class PortMessage {
var imgSrc: String? = null
var base64Data: String? = null
var value : String? = null
var url : String? = null
}
class BookContents {
var chapterTitle : String? = null

View File

@ -90,7 +90,7 @@
android:ellipsize="middle"
android:singleLine="true" />
<TextView android:id="@+id/btn_dl_video" android:text="download" style="@style/MaterialIconButtonStyle" android:visibility="gone" />
<TextView android:id="@+id/btn_dl_video" android:text="download" style="@style/MaterialIconButtonStyle" />
<TextView android:id="@+id/btn_share" android:text="share" style="@style/MaterialIconButtonStyle" />
</LinearLayout>
</merge>

View File

@ -6,7 +6,7 @@
android:padding="0dp"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@android:color/transparent"
android:background="#66000000"
android:orientation="vertical"
>
<androidx.fragment.app.FragmentContainerView