Merge branch 'split_ui' into modular

modular
RyuJongJin 2026-10-22 20:40:06 +09:00
commit 0943427cd0
30 changed files with 2315 additions and 1797 deletions

View File

@ -0,0 +1,196 @@
package com.jj.jj_mkvmaker
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 앱 자체 (비밀 보관 · 뒤로 보내기 · 백그라운드 · 끝내기 · 받은 파일 가져가기 · 업데이트 설치 · 아이콘 · 작업 알림) 채널 처리기.
/// 약속:
/// - 입력: 메서드 secretReadAll · secretWrite · secretDelete · moveToBack · setBackground · exitApp · takeOpenedFiles · takeShowJobs ·
/// installApk · canInstallPackages · uninstallSelf · setAppIcon · keepAlive · notificationTexts · notifyDone · stopKeepAlive
/// 와 그 인자 (나누기 전과 같음). 켜질 때 받은 것은 [inbox] 에서 꺼낸다.
/// - 출력: 메서드마다 같은 답 (값 · null). 맡지 않은 메서드면 false.
/// - 오류: 비밀 보관 실패 "secret", 설치 허용이 꺼져 있으면 "PERMISSION", 그 밖의 설치 실패 "INSTALL".
class AppChannel(private val activity: Activity, private val inbox: LaunchInbox) : ChannelModule {
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
with(activity) {
when (call.method) {
"secretReadAll" -> {
try {
result.success(SecretBox.readAll(applicationContext))
} catch (e: Exception) {
result.error("secret", e.toString(), null)
}
}
"secretWrite" -> {
try {
SecretBox.write(applicationContext, call.argument<String>("key")!!, call.argument<String>("value")!!)
result.success(null)
} catch (e: Exception) {
result.error("secret", e.toString(), null)
}
}
"secretDelete" -> {
SecretBox.delete(applicationContext, call.argument<String>("key")!!)
result.success(null)
}
"moveToBack" -> {
// 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속)
moveTaskToBack(true)
result.success(null)
}
"setBackground" -> {
MainActivity.background = call.argument<Boolean>("on") ?: false
result.success(null)
}
"exitApp" -> {
// [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다
result.success(null)
MainActivity.background = false
stopService(Intent(applicationContext, KeepAliveService::class.java))
FlutterEngineCache.getInstance().remove(MainActivity.ENGINE_ID)
finishAndRemoveTask()
android.os.Handler(mainLooper).postDelayed({
android.os.Process.killProcess(android.os.Process.myPid())
}, 300)
}
"takeOpenedFiles" -> {
result.success(inbox.openFiles)
inbox.openFiles = emptyList()
}
"takeShowJobs" -> {
result.success(inbox.showJobs)
inbox.showJobs = false
}
"installApk" -> try {
installApk(call.argument<String>("path") ?: "")
result.success(null)
} catch (e: InstallPermissionNeededException) {
// 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다
result.error("PERMISSION", e.message ?: e.toString(), null)
} catch (e: Exception) {
// 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다)
result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null)
}
// 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지
"canInstallPackages" -> result.success(
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
)
// 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다
"uninstallSelf" -> {
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
result.success(null)
}
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
call.argument<String>("icon") ?: "")
result.success(null)
}
"notificationTexts" -> {
// 57: 알림 글 · 채널 이름 (앱 화면 언어)
val m = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
KeepAliveService.setTexts(applicationContext,
m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap())
result.success(null)
}
"notifyDone" -> {
KeepAliveService.notifyDone(applicationContext, call.argument<String>("title") ?: "",
call.argument<String>("text") ?: "")
result.success(null)
}
"stopKeepAlive" -> {
stopService(Intent(applicationContext, KeepAliveService::class.java))
result.success(null)
}
else -> return false
}
}
return true
}
/// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다)
private fun Activity.installApk(path: String) {
val src = File(path)
if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
}
// FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
val dir = File(cacheDir, "updates").apply { mkdirs() }
val apk = File(dir, "update.apk")
if (src.absolutePath != apk.absolutePath) {
apk.delete()
if (!src.renameTo(apk)) {
src.copyTo(apk, overwrite = true)
src.delete()
}
}
val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk)
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
/// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별)
class InstallPermissionNeededException(message: String) : Exception(message)
/// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로)
private fun Activity.setAppIcon(id: String): Boolean {
val ids = listOf("yellow", "black", "film_jj", "film", "blue")
if (id !in ids) return false
val pm = packageManager
for (i in ids) {
val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) }
val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
val cn = android.content.ComponentName(this, name)
if (pm.getComponentEnabledSetting(cn) != state) {
pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP)
}
}
return true
}
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
private var askedNotifications = false
private fun Activity.keepAlive(text: String, progress: Int, icon: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
askedNotifications = true
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
}
if (KeepAliveService.running) {
KeepAliveService.update(applicationContext, text, progress, icon)
return
}
val i = Intent(applicationContext, KeepAliveService::class.java)
.putExtra(KeepAliveService.EXTRA_TEXT, text)
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
.putExtra(KeepAliveService.EXTRA_ICON, icon)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i)
else applicationContext.startService(i)
} catch (e: Exception) {
// 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
android.util.Log.w("jj_mkvmaker", "keepAlive", e)
}
}
}

View File

@ -0,0 +1,31 @@
package com.jj.jj_mkvmaker
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/// "jj_mkvmaker/android" 채널을 기능마다 나눈 처리기 (MainActivity 는 등록만 한다).
/// 약속:
/// - 입력: Dart 의 메서드 호출 (메서드 이름 · 인자 - 이름 · 인자는 나누기 전과 같다).
/// - 출력: 맡은 메서드면 [result] 에 답하고 true, 아니면 아무것도 하지 않고 false (다음 처리기로).
/// - 오류: 메서드마다 정한 대로 result.error 또는 실패 값 (false · null) 으로 답한다 (던지지 않음).
interface ChannelModule {
fun handle(call: MethodCall, result: MethodChannel.Result): Boolean
}
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트 · 앱 목록 등) 은 메인 스레드 밖에서 하고, 답은 메인 스레드에서.
/// 실패하면 오류 코드 "TOOLS" (예외 이름: 맨 안쪽 원인)
fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
val main = Handler(Looper.getMainLooper())
Thread {
try {
val value = work()
main.post { result.success(value) }
} catch (e: Throwable) {
android.util.Log.e("jj_mkvmaker", "download tools", e)
val cause = generateSequence(e) { it.cause }.last()
main.post { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) }
}
}.start()
}

View File

@ -1,32 +1,21 @@
package com.jj.jj_mkvmaker package com.jj.jj_mkvmaker
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.webkit.ProfileStore
import androidx.webkit.WebViewFeature
import com.yausername.aria2c.Aria2c
import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비 /// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비.
/// "jj_mkvmaker/android" 채널은 기능마다 나눈 처리기 ([AppChannel] · [StorageChannel] · [SystemChannel]) 를 등록만 한다.
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
companion object { companion object {
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다 /// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS" const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
private const val ENGINE_ID = "main" /// 살려 두는 엔진의 이름 ([AppChannel] 의 exitApp 도 지운다)
internal const val ENGINE_ID = "main"
/// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기) /// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기)
/// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠). /// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠).
@ -63,233 +52,31 @@ class MainActivity : FlutterActivity() {
private var channel: MethodChannel? = null private var channel: MethodChannel? = null
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다) /// 앱이 켜질 때 받은 것 (작업 알림으로 열렸는지 · 다른 앱에서 연 동영상: Dart 가 준비되면 가져간다)
private var pendingShowJobs = false private val inbox = LaunchInbox()
private val opened by lazy { OpenedFiles(this) }
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
private var pendingOpen: List<Map<String, String?>> = emptyList()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
pendingOpen = openedFiles(intent) inbox.openFiles = opened.of(intent)
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS inbox.showJobs = intent?.action == ACTION_SHOW_JOBS
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
channel = ch channel = ch
// 88: SMB (smbj) - 탐색기의 SMB 서버 탭 // 88: SMB (smbj) - 탐색기의 SMB 서버 탭
SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger) SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger)
if (reusedEngine) { if (reusedEngine) {
// Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다 // Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다
if (pendingShowJobs) ch.invokeMethod("showJobs", null) if (inbox.showJobs) ch.invokeMethod("showJobs", null)
if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen) if (inbox.openFiles.isNotEmpty()) ch.invokeMethod("openFiles", inbox.openFiles)
pendingShowJobs = false inbox.showJobs = false
pendingOpen = emptyList() inbox.openFiles = emptyList()
} }
val modules = listOf(AppChannel(this, inbox), StorageChannel(this), SystemChannel(this))
ch.setMethodCallHandler { call, result -> ch.setMethodCallHandler { call, result ->
when (call.method) { if (modules.none { it.handle(call, result) }) result.notImplemented()
"secretReadAll" -> {
try {
result.success(SecretBox.readAll(applicationContext))
} catch (e: Exception) {
result.error("secret", e.toString(), null)
}
}
"secretWrite" -> {
try {
SecretBox.write(applicationContext, call.argument<String>("key")!!, call.argument<String>("value")!!)
result.success(null)
} catch (e: Exception) {
result.error("secret", e.toString(), null)
}
}
"secretDelete" -> {
SecretBox.delete(applicationContext, call.argument<String>("key")!!)
result.success(null)
}
"moveToBack" -> {
// 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속)
moveTaskToBack(true)
result.success(null)
}
"setBackground" -> {
background = call.argument<Boolean>("on") ?: false
result.success(null)
}
"exitApp" -> {
// [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다
result.success(null)
background = false
stopService(Intent(applicationContext, KeepAliveService::class.java))
FlutterEngineCache.getInstance().remove(ENGINE_ID)
finishAndRemoveTask()
android.os.Handler(mainLooper).postDelayed({
android.os.Process.killProcess(android.os.Process.myPid())
}, 300)
}
"takeOpenedFiles" -> {
result.success(pendingOpen)
pendingOpen = emptyList()
}
"takeShowJobs" -> {
result.success(pendingShowJobs)
pendingShowJobs = false
}
"hasAllFilesAccess" -> result.success(hasAllFilesAccess())
"requestAllFilesAccess" -> {
requestAllFilesAccess()
result.success(null)
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
// 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳
"nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir)
// Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음)
"torWebSupport" -> result.success(mapOf(
"proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE),
"multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE),
"documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT),
))
// Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시)
"torProfileClear" -> result.success(clearWebProfile(call.argument<String>("name") ?: "jj_tor"))
"storageVolumes" -> result.success(storageVolumes())
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
"installApk" -> try {
installApk(call.argument<String>("path") ?: "")
result.success(null)
} catch (e: InstallPermissionNeededException) {
// 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다
result.error("PERMISSION", e.message ?: e.toString(), null)
} catch (e: Exception) {
// 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다)
result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null)
}
// 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지
"canInstallPackages" -> result.success(
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
)
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐)
"shizukuStatus" -> result.success(ShizukuBridge.status())
"shizukuRequest" -> shizukuRequest(result)
"shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument<String>("path") ?: "") }
"shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument<String>("path") ?: "") }
"shizukuCopy" -> inBackground(result) {
ShizukuBridge.copyTo(call.argument<String>("path") ?: "", call.argument<String>("dest") ?: "")
}
// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단: 모든 앱 목록 권한 없이 런처에 보이는 앱만)
"launcherApps" -> inBackground(result) { launcherApps() }
"appIcon" -> inBackground(result) { appIcon(call.argument<String>("package") ?: "", call.argument<Int>("size") ?: 96) }
"appDetails" -> result.success(try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + (call.argument<String>("package") ?: "")))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
})
// 지우기는 시스템의 지우기 창 (사용자가 거기서 확인)
"uninstallApp" -> result.success(try {
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:" + (call.argument<String>("package") ?: "")))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
})
// X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히)
"mediaScan" -> result.success(try {
val paths = call.argument<List<String>>("paths") ?: emptyList()
if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null)
true
} catch (e: Exception) {
false
})
// 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다
"uninstallSelf" -> {
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
result.success(null)
}
// 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다)
"netKinds" -> result.success(netKinds())
// X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi).
// VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제
"activeMetered" -> result.success(try {
(getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered
} catch (e: Exception) {
true
})
"diskSpace" -> result.success(
try {
val st = android.os.StatFs(call.argument<String>("path") ?: "")
listOf(st.availableBytes, st.totalBytes)
} catch (e: Exception) {
null
}
)
"openWith" -> result.success(
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
)
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("paths") ?: emptyList()))
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
call.argument<String>("icon") ?: "")
result.success(null)
}
"notificationTexts" -> {
// 57: 알림 글 · 채널 이름 (앱 화면 언어)
val m = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
KeepAliveService.setTexts(applicationContext,
m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap())
result.success(null)
}
"notifyDone" -> {
KeepAliveService.notifyDone(applicationContext, call.argument<String>("title") ?: "",
call.argument<String>("text") ?: "")
result.success(null)
}
"stopKeepAlive" -> {
stopService(Intent(applicationContext, KeepAliveService::class.java))
result.success(null)
}
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
"downloadToolsUpdate" -> inBackground(result) {
YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: ""
}
else -> result.notImplemented()
}
} }
} }
/// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다)
private fun installApk(path: String) {
val src = File(path)
if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
}
// FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
val dir = File(cacheDir, "updates").apply { mkdirs() }
val apk = File(dir, "update.apk")
if (src.absolutePath != apk.absolutePath) {
apk.delete()
if (!src.renameTo(apk)) {
src.copyTo(apk, overwrite = true)
src.delete()
}
}
val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk)
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
/// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별)
class InstallPermissionNeededException(message: String) : Exception(message)
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop) /// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
@ -297,462 +84,7 @@ class MainActivity : FlutterActivity() {
channel?.invokeMethod("showJobs", null) channel?.invokeMethod("showJobs", null)
return return
} }
val files = openedFiles(intent) val files = opened.of(intent)
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files) if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
} }
/// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}]
private fun openedFiles(intent: Intent?): List<Map<String, String?>> {
if (intent == null) return emptyList()
val uris = mutableListOf<Uri>()
when (intent.action) {
Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) }
Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) }
Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) }
}
return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) }
}
@Suppress("DEPRECATION")
private fun intentStream(i: Intent): Uri? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableExtra(Intent.EXTRA_STREAM)
@Suppress("DEPRECATION")
private fun intentStreams(i: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
private fun displayName(uri: Uri): String? = try {
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
}
} catch (e: Exception) {
null
} ?: uri.lastPathSegment?.substringAfterLast('/')
/// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null.
private fun realPath(uri: Uri): String? {
fun ok(path: String?) = path != null && File(path).isFile
if (uri.scheme == "file") return uri.path?.takeIf { ok(it) }
if (uri.scheme != "content") return null
// 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4)
try {
if (android.provider.DocumentsContract.isDocumentUri(this, uri) &&
uri.authority == "com.android.externalstorage.documents"
) {
val id = android.provider.DocumentsContract.getDocumentId(uri)
val vol = id.substringBefore(':')
val rel = id.substringAfter(':', "")
val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol"
val path = "$base/$rel"
if (ok(path)) return path
}
} catch (e: Exception) {
}
// 2. 미디어 저장소 등이 알려 주는 경로 (_data)
try {
contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use {
if (it.moveToFirst()) {
val path = it.getString(0)
if (ok(path)) return path
}
}
} catch (e: Exception) {
}
// 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서)
// 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만)
val size = try {
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use {
if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L
} ?: -1L
} catch (e: Exception) {
-1L
}
val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() }
val roots = storageVolumes().map { it["path"] as String }
for (i in segs.indices) {
val rest = segs.drop(i).joinToString("/")
for (root in roots) {
val f = File(root, rest)
if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath
}
// 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…)
val abs = "/$rest"
if (abs.startsWith("/storage/") && ok(abs)) return abs
}
return null
}
/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}]
private fun storageVolumes(): List<Map<String, Any>> {
val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager
val out = mutableListOf<Map<String, Any>>()
for (v in sm.storageVolumes) {
if (v.state != Environment.MEDIA_MOUNTED) continue
val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
v.directory
} else {
try {
v.javaClass.getMethod("getPathFile").invoke(v) as File?
} catch (e: Exception) {
null
}
}
if (dir == null) continue
out.add(
mapOf(
"path" to dir.absolutePath,
"label" to (v.getDescription(this) ?: dir.name),
"removable" to v.isRemovable,
"primary" to v.isPrimary,
)
)
}
return out
}
/// 웹 주소를 기기의 기본 브라우저로
private fun openUrl(url: String): Boolean = try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
}
/// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false.
/// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로)
private fun setAppIcon(id: String): Boolean {
val ids = listOf("yellow", "black", "film_jj", "film", "blue")
if (id !in ids) return false
val pm = packageManager
for (i in ids) {
val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) }
val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
val cn = android.content.ComponentName(this, name)
if (pm.getComponentEnabledSetting(cn) != state) {
pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP)
}
}
return true
}
/// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창).
/// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml).
private fun openWith(path: String, choose: Boolean): Boolean {
val f = File(path)
if (!f.isFile) return false
val uri = try {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
} catch (e: Exception) {
return false
}
val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*"
val view = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
fun chooser(): Intent = Intent.createChooser(view, null)
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return try {
startActivity(if (choose) chooser() else view)
true
} catch (e: Exception) {
try {
startActivity(chooser())
true
} catch (e2: Exception) {
false
}
}
}
/// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌)
private fun shareFiles(paths: List<String>): Boolean {
val uris = ArrayList<android.net.Uri>()
val mimes = mutableSetOf<String>()
for (p in paths) {
val f = File(p)
if (!f.isFile) continue
uris.add(
try {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
} catch (e: Exception) {
return false
}
)
mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*")
}
if (uris.isEmpty()) return false
// 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */*
val tops = mimes.map { it.substringBefore('/') }.toSet()
val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*"
val send = if (uris.size == 1) {
Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
} else {
Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
}
send.type = mime
// 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다)
val clip = android.content.ClipData.newRawUri(null, uris[0])
for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i]))
send.clipData = clip
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return try {
startActivity(
Intent.createChooser(send, null)
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
true
} catch (e: Exception) {
false
}
}
private fun openFolder(path: String): Boolean {
// 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...")
val root = Environment.getExternalStorageDirectory().path
val (volumeId, rel) = when {
path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/')
path.startsWith("/storage/") -> {
val rest = path.removePrefix("/storage/")
rest.substringBefore('/') to rest.substringAfter('/', "").trim('/')
}
else -> return false
}
// 1. 삼성 "내 파일": 그 폴더로 바로 연다
try {
startActivity(
Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES")
.setPackage("com.sec.android.app.myfiles")
.putExtra("samsung.myfiles.intent.extra.START_PATH", path)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return true
} catch (e: Exception) {
}
// 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다
val uri = android.provider.DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents", "$volumeId:$rel"
)
for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) {
try {
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR)
.apply { if (pkg != null) setPackage(pkg) }
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
return true
} catch (e: Exception) {
}
}
return false
}
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
private var askedNotifications = false
private fun keepAlive(text: String, progress: Int, icon: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
askedNotifications = true
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
}
if (KeepAliveService.running) {
KeepAliveService.update(applicationContext, text, progress, icon)
return
}
val i = Intent(applicationContext, KeepAliveService::class.java)
.putExtra(KeepAliveService.EXTRA_TEXT, text)
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
.putExtra(KeepAliveService.EXTRA_ICON, icon)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i)
else applicationContext.startService(i)
} catch (e: Exception) {
// 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
android.util.Log.w("jj_mkvmaker", "keepAlive", e)
}
}
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
/// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지)
private fun launcherApps(): List<Map<String, Any?>> {
val pm = packageManager
val seen = HashSet<String>()
val out = ArrayList<Map<String, Any?>>()
for (ri in pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)) {
val pkg = ri.activityInfo.packageName
if (!seen.add(pkg)) continue
try {
@Suppress("DEPRECATION")
val pi = pm.getPackageInfo(pkg, 0)
val ai = pi.applicationInfo ?: continue
val dirs = listOfNotNull(ai.sourceDir) + (ai.splitSourceDirs?.toList() ?: emptyList())
out.add(mapOf(
"package" to pkg,
"label" to ri.loadLabel(pm).toString(),
"version" to (pi.versionName ?: ""),
"installed" to pi.firstInstallTime,
"updated" to pi.lastUpdateTime,
"size" to dirs.sumOf { java.io.File(it).length() },
"apk" to ai.sourceDir,
"splits" to (ai.splitSourceDirs?.size ?: 0),
"system" to ((ai.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0),
))
} catch (e: Exception) {
}
}
return out
}
/// 앱 아이콘 (PNG, [size] 픽셀)
private fun appIcon(pkg: String, size: Int): ByteArray? = try {
val d = packageManager.getApplicationIcon(pkg)
val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
d.setBounds(0, 0, size, size)
d.draw(android.graphics.Canvas(bmp))
val bos = java.io.ByteArrayOutputStream()
bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, bos)
bos.toByteArray()
} catch (e: Exception) {
null
}
/// Shizuku 허락 창 - 답 (허락했는지) 을 돌려준다
private var shizukuPending: MethodChannel.Result? = null
private val shizukuListener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { code, grant ->
if (code == 5202) {
shizukuPending?.success(grant == PackageManager.PERMISSION_GRANTED)
shizukuPending = null
}
}
private fun shizukuRequest(result: MethodChannel.Result) {
try {
if (!rikka.shizuku.Shizuku.pingBinder()) return result.success(false)
if (rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return result.success(true)
shizukuPending?.success(false)
shizukuPending = result
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(shizukuListener)
rikka.shizuku.Shizuku.addRequestPermissionResultListener(shizukuListener)
rikka.shizuku.Shizuku.requestPermission(5202)
} catch (e: Throwable) {
shizukuPending = null
result.success(false)
}
}
private fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
Thread {
try {
val value = work()
runOnUiThread { result.success(value) }
} catch (e: Throwable) {
android.util.Log.e("jj_mkvmaker", "download tools", e)
val cause = generateSequence(e) { it.cause }.last()
runOnUiThread { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) }
}
}.start()
}
/// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다.
/// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다.
private fun downloadToolsInit(): Map<String, Any> {
YoutubeDL.init(applicationContext)
FFmpeg.init(applicationContext)
Aria2c.init(applicationContext)
val bin = applicationInfo.nativeLibraryDir
val base = File(noBackupFilesDir, YoutubeDL.baseName)
val packages = File(base, "packages")
val python = File(packages, "python").absolutePath
val ffmpeg = File(packages, "ffmpeg").absolutePath
val aria2c = File(packages, "aria2c").absolutePath
val cert = "$python/usr/etc/tls/cert.pem"
return mapOf(
"python" to "$bin/libpython.so",
"ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath,
"ffmpeg" to "$bin/libffmpeg.so",
"quickjs" to "$bin/libqjs.so",
"aria2c" to "$bin/libaria2c.so",
"cert" to cert,
"version" to (YoutubeDL.versionName(applicationContext) ?: ""),
"env" to mapOf(
"LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib",
"SSL_CERT_FILE" to cert,
"PATH" to (System.getenv("PATH") ?: "") + ":" + bin,
"PYTHONHOME" to "$python/usr",
"HOME" to "$python/usr",
"TMPDIR" to cacheDir.absolutePath,
),
)
}
/// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false
private fun clearWebProfile(name: String): Boolean {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true
val store = ProfileStore.getInstance()
if (store.getProfile(name) == null) return true
return try {
store.deleteProfile(name)
} catch (e: IllegalStateException) {
try {
val profile = store.getProfile(name)
profile?.cookieManager?.removeAllCookies(null)
profile?.webStorage?.deleteAllData()
} catch (_: Exception) {
}
false
}
}
private fun hasAllFilesAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
private fun requestAllFilesAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName")))
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
} else {
requestPermissions(
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1
)
}
}
/// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로)
@Suppress("DEPRECATION")
private fun netKinds(): Map<String, String>? = try {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
val out = HashMap<String, String>()
for (n in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(n) ?: continue
val lp = cm.getLinkProperties(n) ?: continue
val kind = when {
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
else -> "other"
}
for (la in lp.linkAddresses) {
val a = la.address
if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind }
}
}
out
} catch (e: Exception) {
null
}
} }

View File

@ -0,0 +1,106 @@
package com.jj.jj_mkvmaker
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import java.io.File
/// 앱이 켜질 때 받은 것 (Dart 가 준비되면 "takeOpenedFiles" · "takeShowJobs" 로 가져간다)
class LaunchInbox {
/// 다른 앱에서 연 동영상
var openFiles: List<Map<String, String?>> = emptyList()
/// 작업 알림으로 열렸는지
var showJobs = false
}
/// 연결 프로그램 (VIEW) · 공유 (SEND · SEND_MULTIPLE) 로 받은 파일을 읽는 모듈.
/// 약속:
/// - 입력: 받은 [Intent] (null 이면 없음), 쓸 수 있는 저장소 목록 ([storageVolumes] - 경로 찾기에 씀).
/// - 출력: [{path (실제 경로, 모르면 null), uri, name}] 목록. 그 밖의 action 이면 빈 목록.
/// - 오류: 던지지 않는다. 경로 · 이름을 읽지 못하면 null.
class OpenedFiles(private val context: Context) {
fun of(intent: Intent?): List<Map<String, String?>> {
if (intent == null) return emptyList()
val uris = mutableListOf<Uri>()
when (intent.action) {
Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) }
Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) }
Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) }
}
return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) }
}
@Suppress("DEPRECATION")
private fun intentStream(i: Intent): Uri? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableExtra(Intent.EXTRA_STREAM)
@Suppress("DEPRECATION")
private fun intentStreams(i: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
private fun displayName(uri: Uri): String? = try {
context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
}
} catch (e: Exception) {
null
} ?: uri.lastPathSegment?.substringAfterLast('/')
/// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null.
private fun realPath(uri: Uri): String? {
fun ok(path: String?) = path != null && File(path).isFile
if (uri.scheme == "file") return uri.path?.takeIf { ok(it) }
if (uri.scheme != "content") return null
// 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4)
try {
if (android.provider.DocumentsContract.isDocumentUri(context, uri) &&
uri.authority == "com.android.externalstorage.documents"
) {
val id = android.provider.DocumentsContract.getDocumentId(uri)
val vol = id.substringBefore(':')
val rel = id.substringAfter(':', "")
val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol"
val path = "$base/$rel"
if (ok(path)) return path
}
} catch (e: Exception) {
}
// 2. 미디어 저장소 등이 알려 주는 경로 (_data)
try {
context.contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use {
if (it.moveToFirst()) {
val path = it.getString(0)
if (ok(path)) return path
}
}
} catch (e: Exception) {
}
// 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서)
// 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만)
val size = try {
context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use {
if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L
} ?: -1L
} catch (e: Exception) {
-1L
}
val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() }
val roots = context.storageVolumes().map { it["path"] as String }
for (i in segs.indices) {
val rest = segs.drop(i).joinToString("/")
for (root in roots) {
val f = File(root, rest)
if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath
}
// 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…)
val abs = "/$rest"
if (abs.startsWith("/storage/") && ok(abs)) return abs
}
return null
}
}

View File

@ -0,0 +1,235 @@
package com.jj.jj_mkvmaker
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 저장소 · 파일 (전체 접근 · 저장소 목록 · 남은 공간 · 미디어 다시 읽기 · 주소 · 폴더 열기 · 다른 앱으로 열기 · 공유) 채널 처리기.
/// 약속:
/// - 입력: 메서드 hasAllFilesAccess · requestAllFilesAccess · storageRoot · nativeLibDir · storageVolumes · openUrl · openFolder ·
/// mediaScan · diskSpace · openWith · shareFiles 와 그 인자 (나누기 전과 같음).
/// - 출력: 메서드마다 같은 답 (참 · 거짓 · 경로 · 목록 · [남은, 전체] 바이트). 맡지 않은 메서드면 false.
/// - 오류: 던지지 않는다. 열 앱이 없거나 읽지 못하면 false · null.
class StorageChannel(private val activity: Activity) : ChannelModule {
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
with(activity) {
when (call.method) {
"hasAllFilesAccess" -> result.success(hasAllFilesAccess())
"requestAllFilesAccess" -> {
requestAllFilesAccess()
result.success(null)
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
// 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳
"nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir)
"storageVolumes" -> result.success(storageVolumes())
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
// X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히)
"mediaScan" -> result.success(try {
val paths = call.argument<List<String>>("paths") ?: emptyList()
if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null)
true
} catch (e: Exception) {
false
})
"diskSpace" -> result.success(
try {
val st = android.os.StatFs(call.argument<String>("path") ?: "")
listOf(st.availableBytes, st.totalBytes)
} catch (e: Exception) {
null
}
)
"openWith" -> result.success(
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
)
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("paths") ?: emptyList()))
else -> return false
}
}
return true
}
/// 웹 주소를 기기의 기본 브라우저로
private fun Activity.openUrl(url: String): Boolean = try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
}
/// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창).
/// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml).
private fun Activity.openWith(path: String, choose: Boolean): Boolean {
val f = File(path)
if (!f.isFile) return false
val uri = try {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
} catch (e: Exception) {
return false
}
val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*"
val view = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
fun chooser(): Intent = Intent.createChooser(view, null)
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return try {
startActivity(if (choose) chooser() else view)
true
} catch (e: Exception) {
try {
startActivity(chooser())
true
} catch (e2: Exception) {
false
}
}
}
/// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌)
private fun Activity.shareFiles(paths: List<String>): Boolean {
val uris = ArrayList<android.net.Uri>()
val mimes = mutableSetOf<String>()
for (p in paths) {
val f = File(p)
if (!f.isFile) continue
uris.add(
try {
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
} catch (e: Exception) {
return false
}
)
mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*")
}
if (uris.isEmpty()) return false
// 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */*
val tops = mimes.map { it.substringBefore('/') }.toSet()
val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*"
val send = if (uris.size == 1) {
Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
} else {
Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
}
send.type = mime
// 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다)
val clip = android.content.ClipData.newRawUri(null, uris[0])
for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i]))
send.clipData = clip
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return try {
startActivity(
Intent.createChooser(send, null)
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
true
} catch (e: Exception) {
false
}
}
/// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false.
private fun Activity.openFolder(path: String): Boolean {
// 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...")
val root = Environment.getExternalStorageDirectory().path
val (volumeId, rel) = when {
path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/')
path.startsWith("/storage/") -> {
val rest = path.removePrefix("/storage/")
rest.substringBefore('/') to rest.substringAfter('/', "").trim('/')
}
else -> return false
}
// 1. 삼성 "내 파일": 그 폴더로 바로 연다
try {
startActivity(
Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES")
.setPackage("com.sec.android.app.myfiles")
.putExtra("samsung.myfiles.intent.extra.START_PATH", path)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return true
} catch (e: Exception) {
}
// 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다
val uri = android.provider.DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents", "$volumeId:$rel"
)
for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) {
try {
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR)
.apply { if (pkg != null) setPackage(pkg) }
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
return true
} catch (e: Exception) {
}
}
return false
}
private fun Activity.hasAllFilesAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
private fun Activity.requestAllFilesAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName")))
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
} else {
requestPermissions(
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1
)
}
}
}
/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}]
fun Context.storageVolumes(): List<Map<String, Any>> {
val sm = getSystemService(Context.STORAGE_SERVICE) as android.os.storage.StorageManager
val out = mutableListOf<Map<String, Any>>()
for (v in sm.storageVolumes) {
if (v.state != Environment.MEDIA_MOUNTED) continue
val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
v.directory
} else {
try {
v.javaClass.getMethod("getPathFile").invoke(v) as File?
} catch (e: Exception) {
null
}
}
if (dir == null) continue
out.add(
mapOf(
"path" to dir.absolutePath,
"label" to (v.getDescription(this) ?: dir.name),
"removable" to v.isRemovable,
"primary" to v.isPrimary,
)
)
}
return out
}

View File

@ -0,0 +1,223 @@
package com.jj.jj_mkvmaker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.webkit.ProfileStore
import androidx.webkit.WebViewFeature
import com.yausername.aria2c.Aria2c
import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 기기 · 시스템 (Tor 웹 뷰 · Shizuku 시스템 폴더 · 앱 관리자 · 망 종류 · 다운로드 프로그램) 채널 처리기.
/// 약속:
/// - 입력: 메서드 torWebSupport · torProfileClear · shizukuStatus · shizukuRequest · shizukuList · shizukuStat · shizukuCopy ·
/// launcherApps · appIcon · appDetails · uninstallApp · netKinds · activeMetered · downloadToolsInit · downloadToolsUpdate
/// 와 그 인자 (나누기 전과 같음).
/// - 출력: 메서드마다 같은 답 (지도 · 목록 · PNG 바이트 · 참 · 거짓). 오래 걸리는 것은 [inBackground] 로. 맡지 않은 메서드면 false.
/// - 오류: 뒤에서 하는 일이 실패하면 "TOOLS", 그 밖에는 false · null (Shizuku 허락 실패는 false).
class SystemChannel(private val activity: Activity) : ChannelModule {
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
with(activity) {
when (call.method) {
// Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음)
"torWebSupport" -> result.success(mapOf(
"proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE),
"multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE),
"documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT),
))
// Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시)
"torProfileClear" -> result.success(clearWebProfile(call.argument<String>("name") ?: "jj_tor"))
// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐)
"shizukuStatus" -> result.success(ShizukuBridge.status())
"shizukuRequest" -> shizukuRequest(result)
"shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument<String>("path") ?: "") }
"shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument<String>("path") ?: "") }
"shizukuCopy" -> inBackground(result) {
ShizukuBridge.copyTo(call.argument<String>("path") ?: "", call.argument<String>("dest") ?: "")
}
// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단: 모든 앱 목록 권한 없이 런처에 보이는 앱만)
"launcherApps" -> inBackground(result) { launcherApps() }
"appIcon" -> inBackground(result) { appIcon(call.argument<String>("package") ?: "", call.argument<Int>("size") ?: 96) }
"appDetails" -> result.success(try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + (call.argument<String>("package") ?: "")))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
})
// 지우기는 시스템의 지우기 창 (사용자가 거기서 확인)
"uninstallApp" -> result.success(try {
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:" + (call.argument<String>("package") ?: "")))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
})
// 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다)
"netKinds" -> result.success(netKinds())
// X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi).
// VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제
"activeMetered" -> result.success(try {
(getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered
} catch (e: Exception) {
true
})
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
"downloadToolsUpdate" -> inBackground(result) {
YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: ""
}
else -> return false
}
}
return true
}
/// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지)
private fun Activity.launcherApps(): List<Map<String, Any?>> {
val pm = packageManager
val seen = HashSet<String>()
val out = ArrayList<Map<String, Any?>>()
for (ri in pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)) {
val pkg = ri.activityInfo.packageName
if (!seen.add(pkg)) continue
try {
@Suppress("DEPRECATION")
val pi = pm.getPackageInfo(pkg, 0)
val ai = pi.applicationInfo ?: continue
val dirs = listOfNotNull(ai.sourceDir) + (ai.splitSourceDirs?.toList() ?: emptyList())
out.add(mapOf(
"package" to pkg,
"label" to ri.loadLabel(pm).toString(),
"version" to (pi.versionName ?: ""),
"installed" to pi.firstInstallTime,
"updated" to pi.lastUpdateTime,
"size" to dirs.sumOf { java.io.File(it).length() },
"apk" to ai.sourceDir,
"splits" to (ai.splitSourceDirs?.size ?: 0),
"system" to ((ai.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0),
))
} catch (e: Exception) {
}
}
return out
}
/// 앱 아이콘 (PNG, [size] 픽셀)
private fun Activity.appIcon(pkg: String, size: Int): ByteArray? = try {
val d = packageManager.getApplicationIcon(pkg)
val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
d.setBounds(0, 0, size, size)
d.draw(android.graphics.Canvas(bmp))
val bos = java.io.ByteArrayOutputStream()
bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, bos)
bos.toByteArray()
} catch (e: Exception) {
null
}
/// Shizuku 허락 창 - 답 (허락했는지) 을 돌려준다
private var shizukuPending: MethodChannel.Result? = null
private val shizukuListener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { code, grant ->
if (code == 5202) {
shizukuPending?.success(grant == PackageManager.PERMISSION_GRANTED)
shizukuPending = null
}
}
private fun Activity.shizukuRequest(result: MethodChannel.Result) {
try {
if (!rikka.shizuku.Shizuku.pingBinder()) return result.success(false)
if (rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return result.success(true)
shizukuPending?.success(false)
shizukuPending = result
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(shizukuListener)
rikka.shizuku.Shizuku.addRequestPermissionResultListener(shizukuListener)
rikka.shizuku.Shizuku.requestPermission(5202)
} catch (e: Throwable) {
shizukuPending = null
result.success(false)
}
}
/// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다.
/// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다.
private fun Activity.downloadToolsInit(): Map<String, Any> {
YoutubeDL.init(applicationContext)
FFmpeg.init(applicationContext)
Aria2c.init(applicationContext)
val bin = applicationInfo.nativeLibraryDir
val base = File(noBackupFilesDir, YoutubeDL.baseName)
val packages = File(base, "packages")
val python = File(packages, "python").absolutePath
val ffmpeg = File(packages, "ffmpeg").absolutePath
val aria2c = File(packages, "aria2c").absolutePath
val cert = "$python/usr/etc/tls/cert.pem"
return mapOf(
"python" to "$bin/libpython.so",
"ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath,
"ffmpeg" to "$bin/libffmpeg.so",
"quickjs" to "$bin/libqjs.so",
"aria2c" to "$bin/libaria2c.so",
"cert" to cert,
"version" to (YoutubeDL.versionName(applicationContext) ?: ""),
"env" to mapOf(
"LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib",
"SSL_CERT_FILE" to cert,
"PATH" to (System.getenv("PATH") ?: "") + ":" + bin,
"PYTHONHOME" to "$python/usr",
"HOME" to "$python/usr",
"TMPDIR" to cacheDir.absolutePath,
),
)
}
/// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false
private fun Activity.clearWebProfile(name: String): Boolean {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true
val store = ProfileStore.getInstance()
if (store.getProfile(name) == null) return true
return try {
store.deleteProfile(name)
} catch (e: IllegalStateException) {
try {
val profile = store.getProfile(name)
profile?.cookieManager?.removeAllCookies(null)
profile?.webStorage?.deleteAllData()
} catch (_: Exception) {
}
false
}
}
/// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로)
@Suppress("DEPRECATION")
private fun Activity.netKinds(): Map<String, String>? = try {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
val out = HashMap<String, String>()
for (n in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(n) ?: continue
val lp = cm.getLinkProperties(n) ?: continue
val kind = when {
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
else -> "other"
}
for (la in lp.linkAddresses) {
val a = la.address
if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind }
}
}
out
} catch (e: Exception) {
null
}
}

195
lib/app/ai_jobs.dart Normal file
View File

@ -0,0 +1,195 @@
// 진행 중인 AI 작업 하나 (그리기 · 여러 장 해상도 올리기) 를 맡는 모듈 (ai_local.dart 에서 나눔).
// 약속:
// - 입력: 엔진 [ImageAiEngine] · 요청 [ImageGenRequest] · 저장 폴더 ([AiJobs.run]), 장 수 · 장마다 할 일 ([AiJobs.runTask]).
// - 출력: 만든 그림 목록 (그림마다 옆에 같은 이름의 .json) · 끝낸 장 수. 진행 · 남은 장 · 예상 시간은
// 필드 ([title] · [progress] · [remaining] · [eta]) 와 [statusLine] 으로, 바뀔 때마다 notifyListeners.
// - 오류: 이미 작업 중이면 [ImageAiException]. 엔진 · 할 일의 오류는 그대로 올리고, 끝나면 늘 [finish] 로 비운다.
// 화면 상태에 기대지 않는다 (작업 알림 · 모니터링 · 화면이 [AiJobs.instance] 를 함께 본다).
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../core/ai_catalog.dart';
import '../core/sd_cli.dart';
import '../l10n/tr.dart';
import '../services/image_ai.dart';
import 'ai_local_engine.dart';
/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다)
class AiJobs extends ChangeNotifier {
static final instance = AiJobs();
/// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간
String? title;
double? progress;
int remaining = 0;
Duration? eta;
ImageAiEngine? _engine;
void Function()? _cancelTask;
bool get busy => title != null;
/// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다
String? lastDone;
String? lastDoneDir;
/// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기]
Future<void> Function()? lastDoneView;
void Function()? _lastDoneDispose;
/// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등)
void done(String text, {String? dir, Future<void> Function()? view, void Function()? dispose}) {
_clearDone();
lastDone = text;
lastDoneDir = dir;
lastDoneView = view;
_lastDoneDispose = dispose;
notifyListeners();
}
/// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다
void clearDoneView() {
lastDoneView = null;
_lastDoneDispose = null;
notifyListeners();
}
void _clearDone() {
_lastDoneDispose?.call();
_lastDoneDispose = null;
lastDoneView = null;
lastDone = null;
lastDoneDir = null;
}
void start(String t, ImageAiEngine engine, int count) {
title = t;
_engine = engine;
total = count;
remaining = count;
progress = 0;
eta = null;
_clearDone();
notifyListeners();
}
void update({double? progress, int? remaining, Duration? eta, String? title}) {
this.progress = progress ?? this.progress;
this.remaining = remaining ?? this.remaining;
this.eta = eta ?? this.eta;
this.title = title ?? this.title;
notifyListeners();
}
/// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다)
Set<String> inUse = const {};
void finish() {
inUse = const {};
title = null;
progress = null;
total = 0;
remaining = 0;
eta = null;
_engine = null;
notifyListeners();
}
void cancel() {
_engine?.cancel();
_cancelTask?.call();
}
/// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다.
/// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다.
Future<int> runTask(String title, int count, Future<void> Function(int i, void Function(double? p) progress) body,
{required void Function() onCancel, Set<String> uses = const {}}) async {
if (busy) throw ImageAiException(tr('이미 작업 중입니다'));
inUse = uses;
_clearDone();
var stop = false;
this.title = title;
total = count;
remaining = count;
progress = 0;
eta = null;
_cancelTask = () {
stop = true;
onCancel();
};
notifyListeners();
final started = DateTime.now();
var done = 0;
try {
for (var i = 0; i < count && !stop; i++) {
await body(i, (pp) {
final overall = (i + (pp ?? 0)) / count;
final spent = DateTime.now().difference(started);
update(
progress: overall,
remaining: count - i,
eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null,
);
});
done++;
}
} finally {
_cancelTask = null;
finish();
}
return done;
}
/// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다.
Future<List<GeneratedImage>> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async {
if (busy) throw ImageAiException(tr('이미 그리는 중입니다'));
final started = DateTime.now();
final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first;
start(trf('AI 그림 ({0})', [engine.label]), engine, r.count);
if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'};
try {
final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) {
Duration? left;
// 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다
if (prog != null && prog > 0.12) {
final spent = DateTime.now().difference(started);
left = spent * ((1 - prog) / prog);
}
// update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다)
eta = left;
update(
progress: prog,
title: trf('AI 그림: {0}', [step]),
remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count),
eta: left,
);
});
for (final g in made) {
try {
await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed));
} catch (_) {}
}
return made;
} finally {
finish();
}
}
/// 알림 · 모니터링에 보일 한 줄
String? get statusLine {
final t = title;
if (t == null) return null;
final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}';
// 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면)
final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : '';
return '$t$at$left';
}
/// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째")
int total = 0;
static String durationText(Duration d) => _dur(d);
static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]);
}

View File

@ -1,3 +1,11 @@
// 기기 안 AI 파일 (모델 · 실행 파일) 받기 · 지우기 저장소 [AiStore] 와 처리 장치 고르기 ([sdDevices] · [pickAiDevice]).
// 엔진 실행은 ai_local_engine.dart, 진행 중인 작업은 ai_jobs.dart (이 파일에서 나눔).
// 약속:
// - 입력: 앱 데이터 폴더 · 카탈로그 파일 [AiFile] (받기 · 지우기), 설정 (aiDevice · aiAutoDevice · aiAutoDeviceFor · aiTaesd).
// - 출력: ai/ 아래에 받은 파일 · 표시 파일 (installed/<id>.json, sha256), 진행 (progress · received · totals · queued),
// 쓸 수 있는 처리 장치 목록 · 고른 장치 (auto 면 짧게 재서 고르고 설정에 기억).
// - 오류: 공간 부족 · 손상 (SHA-256 다름) · 취소 · 엔진이 이 기기에서 못 돎 → [ImageAiException] (쉬운 말),
// 연결이 끊기면 몇 번 다시 잇고 그래도 안 되면 failures 에 남기고 [ImageAiException].
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@ -6,13 +14,14 @@ import 'package:archive/archive_io.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../core/ai_catalog.dart'; import '../core/ai_catalog.dart';
import '../core/sd_cli.dart'; import '../core/sd_cli.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../services/image_ai.dart'; import '../services/image_ai.dart';
import 'ai_jobs.dart';
import 'ai_local_engine.dart';
import 'app_controller.dart'; import 'app_controller.dart';
import 'component_store.dart' show AccumulatorSink; import 'component_store.dart' show AccumulatorSink;
import 'copy_center.dart' show diskSpace; import 'copy_center.dart' show diskSpace;
@ -407,173 +416,6 @@ int? contentRangeStart(String? header) {
return m == null ? null : int.parse(m.group(1)!); return m == null ? null : int.parse(m.group(1)!);
} }
/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값)
class SdDevice {
final String exe;
final String backend;
final String label;
const SdDevice(this.exe, this.backend, this.label);
String get key => '${p.basename(p.dirname(exe))}:$backend';
}
/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기)
class LocalSdEngine implements ImageAiEngine {
LocalSdEngine({required this.device, required this.models, this.threads = 0});
final SdDevice device;
final SdModelPaths models;
final int threads;
Process? _proc;
bool _cancelled = false;
@override
String get label => trf('이 기기 ({0})', [device.label]);
@override
bool get leavesDevice => false;
@override
void cancel() {
_cancelled = true;
_proc?.kill();
}
@override
Future<List<GeneratedImage>> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async {
_cancelled = false;
await Directory(outDir).create(recursive: true);
final seed = resolveSeed(r.seed);
final req = ImageGenRequest(
prompt: r.prompt,
negative: r.negative,
width: r.width,
height: r.height,
steps: r.steps,
cfg: r.cfg,
seed: seed,
count: r.count,
initImage: r.initImage,
strength: r.strength,
model: r.model,
);
final out = p.join(outDir, '${baseName}_%d.png');
final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads);
final List<String> lines;
try {
lines = await runSdCli(device.exe, args, onProgress: (prog) {
final step = switch (prog.phase) {
// 152: 읽는 동안은 남은 시간을 모른다
'load' => tr('모델 읽는 중 …'),
'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]),
'decode' => tr('그림으로 바꾸는 중'),
_ => tr('저장하는 중'),
};
onProgress?.call(prog.overall, step);
}, onStart: (pr) => _proc = pr);
} catch (_) {
// 150: 취소로 엔진을 끝낸 것은 실패가 아니다
if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true);
rethrow;
}
if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true);
final made = Directory(outDir)
.listSync()
.whereType<File>()
.where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path)))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')]));
// 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것
for (final f in made) {
if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) {
for (final x in made) {
try {
x.deleteSync();
} catch (_) {}
}
throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n'));
}
}
return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)];
}
}
/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음)
bool sizeMatches(Uint8List bytes, int w, int h) {
final info = img.findDecoderForData(bytes)?.startDecode(bytes);
return info != null && info.width == w && info.height == h;
}
/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함)
bool isSdFatalLine(String line) {
final l = line.toLowerCase();
return const [
'new_upscaler_ctx failed',
'upscale failed',
'backend config failed',
'failed to initialize',
'new_sd_ctx_t failed',
].any(l.contains);
}
/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게)
typedef SdRunner = Future<List<String>> Function(String exe, List<String> args,
{void Function(SdProgress)? onProgress, void Function(Process)? onStart});
/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException]
Future<List<String>> runSdCli(String exe, List<String> args,
{void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async {
final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe));
onStart?.call(proc);
final tail = <String>[];
final fatal = <String>[];
var saved = false;
void feed(String chunk) {
if (chunk.contains('images saved')) saved = true;
for (final piece in chunk.split(RegExp(r'[\r\n]+'))) {
if (piece.trim().isEmpty) continue;
if (isSdFatalLine(piece)) fatal.add(piece.trim());
final prog = parseSdProgress(piece);
if (prog != null) {
onProgress?.call(prog);
} else {
tail.add(piece.trim());
if (tail.length > 8) tail.removeAt(0);
}
}
}
final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
final code = await proc.exitCode;
await Future.wait([a, b]);
// 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다
if (fatal.isNotEmpty) {
throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n'));
}
// 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다
if (code != 0 && !saved) {
final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList();
// 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에
throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n'));
}
return tail;
}
/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판,
/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh)
String sdCliLibName(String cpuinfo) {
final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l];
bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f);
final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp'));
return v82 ? 'libsdcli.so' : 'libsdcli_v80.so';
}
/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다
String sdExitMessage(int code) => switch (code) {
-4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령
-9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'),
_ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]),
};
/// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU) /// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU)
Future<List<SdDevice>> sdDevices(AiStore store) async { Future<List<SdDevice>> sdDevices(AiStore store) async {
final out = <SdDevice>[]; final out = <SdDevice>[];
@ -601,184 +443,6 @@ Future<List<SdDevice>> sdDevices(AiStore store) async {
return out; return out;
} }
/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다)
class AiJobs extends ChangeNotifier {
static final instance = AiJobs();
/// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간
String? title;
double? progress;
int remaining = 0;
Duration? eta;
ImageAiEngine? _engine;
void Function()? _cancelTask;
bool get busy => title != null;
/// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다
String? lastDone;
String? lastDoneDir;
/// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기]
Future<void> Function()? lastDoneView;
void Function()? _lastDoneDispose;
/// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등)
void done(String text, {String? dir, Future<void> Function()? view, void Function()? dispose}) {
_clearDone();
lastDone = text;
lastDoneDir = dir;
lastDoneView = view;
_lastDoneDispose = dispose;
notifyListeners();
}
/// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다
void clearDoneView() {
lastDoneView = null;
_lastDoneDispose = null;
notifyListeners();
}
void _clearDone() {
_lastDoneDispose?.call();
_lastDoneDispose = null;
lastDoneView = null;
lastDone = null;
lastDoneDir = null;
}
void start(String t, ImageAiEngine engine, int count) {
title = t;
_engine = engine;
total = count;
remaining = count;
progress = 0;
eta = null;
_clearDone();
notifyListeners();
}
void update({double? progress, int? remaining, Duration? eta, String? title}) {
this.progress = progress ?? this.progress;
this.remaining = remaining ?? this.remaining;
this.eta = eta ?? this.eta;
this.title = title ?? this.title;
notifyListeners();
}
/// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다)
Set<String> inUse = const {};
void finish() {
inUse = const {};
title = null;
progress = null;
total = 0;
remaining = 0;
eta = null;
_engine = null;
notifyListeners();
}
void cancel() {
_engine?.cancel();
_cancelTask?.call();
}
/// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다.
/// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다.
Future<int> runTask(String title, int count, Future<void> Function(int i, void Function(double? p) progress) body,
{required void Function() onCancel, Set<String> uses = const {}}) async {
if (busy) throw ImageAiException(tr('이미 작업 중입니다'));
inUse = uses;
_clearDone();
var stop = false;
this.title = title;
total = count;
remaining = count;
progress = 0;
eta = null;
_cancelTask = () {
stop = true;
onCancel();
};
notifyListeners();
final started = DateTime.now();
var done = 0;
try {
for (var i = 0; i < count && !stop; i++) {
await body(i, (pp) {
final overall = (i + (pp ?? 0)) / count;
final spent = DateTime.now().difference(started);
update(
progress: overall,
remaining: count - i,
eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null,
);
});
done++;
}
} finally {
_cancelTask = null;
finish();
}
return done;
}
/// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다.
Future<List<GeneratedImage>> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async {
if (busy) throw ImageAiException(tr('이미 그리는 중입니다'));
final started = DateTime.now();
final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first;
start(trf('AI 그림 ({0})', [engine.label]), engine, r.count);
if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'};
try {
final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) {
Duration? left;
// 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다
if (prog != null && prog > 0.12) {
final spent = DateTime.now().difference(started);
left = spent * ((1 - prog) / prog);
}
// update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다)
eta = left;
update(
progress: prog,
title: trf('AI 그림: {0}', [step]),
remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count),
eta: left,
);
});
for (final g in made) {
try {
await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed));
} catch (_) {}
}
return made;
} finally {
finish();
}
}
/// 알림 · 모니터링에 보일 한 줄
String? get statusLine {
final t = title;
if (t == null) return null;
final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}';
// 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면)
final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : '';
return '$t$at$left';
}
/// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째")
int total = 0;
static String durationText(Duration d) => _dur(d);
static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]);
}
/// 그릴 처리 장치. 'auto' 면 한 번 짧게 재서 (256×256 · 1단계) 가장 빠른 것을 기억한다 (장치 목록이 바뀌면 다시 잰다). /// 그릴 처리 장치. 'auto' 면 한 번 짧게 재서 (256×256 · 1단계) 가장 빠른 것을 기억한다 (장치 목록이 바뀌면 다시 잰다).
Future<SdDevice?> pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async { Future<SdDevice?> pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async {
final devices = await sdDevices(store); final devices = await sdDevices(store);

View File

@ -0,0 +1,186 @@
// 기기 안 AI 엔진 (stable-diffusion.cpp 의 sd-cli) 를 실행하는 모듈 (ai_local.dart 에서 나눔).
// 약속:
// - 입력: sd-cli 실행 파일 경로 · 인자 ([runSdCli]), 처리 장치 [SdDevice] · 모델 경로 [SdModelPaths] ([LocalSdEngine]),
// /proc/cpuinfo 글 ([sdCliLibName]), 끝난 코드 ([sdExitMessage]), 그림 바이트 ([sizeMatches]).
// - 출력: 엔진 기록 마지막 줄들 · 만든 그림 [GeneratedImage] 목록 · 고를 .so 이름 · 쉬운 말 한 줄.
// - 오류: 엔진이 실패하거나 (기록에 치명 줄 · 저장 없이 0 이 아닌 코드) 그림 크기가 다르면 [ImageAiException],
// 취소로 끝내면 cancelled: true 인 [ImageAiException].
// 화면 · 받기 저장소 (AiStore) 상태에 기대지 않는다.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import '../core/sd_cli.dart';
import '../l10n/tr.dart';
import '../services/image_ai.dart';
/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값)
class SdDevice {
final String exe;
final String backend;
final String label;
const SdDevice(this.exe, this.backend, this.label);
String get key => '${p.basename(p.dirname(exe))}:$backend';
}
/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기)
class LocalSdEngine implements ImageAiEngine {
LocalSdEngine({required this.device, required this.models, this.threads = 0});
final SdDevice device;
final SdModelPaths models;
final int threads;
Process? _proc;
bool _cancelled = false;
@override
String get label => trf('이 기기 ({0})', [device.label]);
@override
bool get leavesDevice => false;
@override
void cancel() {
_cancelled = true;
_proc?.kill();
}
@override
Future<List<GeneratedImage>> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async {
_cancelled = false;
await Directory(outDir).create(recursive: true);
final seed = resolveSeed(r.seed);
final req = ImageGenRequest(
prompt: r.prompt,
negative: r.negative,
width: r.width,
height: r.height,
steps: r.steps,
cfg: r.cfg,
seed: seed,
count: r.count,
initImage: r.initImage,
strength: r.strength,
model: r.model,
);
final out = p.join(outDir, '${baseName}_%d.png');
final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads);
final List<String> lines;
try {
lines = await runSdCli(device.exe, args, onProgress: (prog) {
final step = switch (prog.phase) {
// 152: 읽는 동안은 남은 시간을 모른다
'load' => tr('모델 읽는 중 …'),
'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]),
'decode' => tr('그림으로 바꾸는 중'),
_ => tr('저장하는 중'),
};
onProgress?.call(prog.overall, step);
}, onStart: (pr) => _proc = pr);
} catch (_) {
// 150: 취소로 엔진을 끝낸 것은 실패가 아니다
if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true);
rethrow;
}
if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true);
final made = Directory(outDir)
.listSync()
.whereType<File>()
.where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path)))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')]));
// 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것
for (final f in made) {
if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) {
for (final x in made) {
try {
x.deleteSync();
} catch (_) {}
}
throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n'));
}
}
return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)];
}
}
/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음)
bool sizeMatches(Uint8List bytes, int w, int h) {
final info = img.findDecoderForData(bytes)?.startDecode(bytes);
return info != null && info.width == w && info.height == h;
}
/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함)
bool isSdFatalLine(String line) {
final l = line.toLowerCase();
return const [
'new_upscaler_ctx failed',
'upscale failed',
'backend config failed',
'failed to initialize',
'new_sd_ctx_t failed',
].any(l.contains);
}
/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게)
typedef SdRunner = Future<List<String>> Function(String exe, List<String> args,
{void Function(SdProgress)? onProgress, void Function(Process)? onStart});
/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException]
Future<List<String>> runSdCli(String exe, List<String> args,
{void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async {
final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe));
onStart?.call(proc);
final tail = <String>[];
final fatal = <String>[];
var saved = false;
void feed(String chunk) {
if (chunk.contains('images saved')) saved = true;
for (final piece in chunk.split(RegExp(r'[\r\n]+'))) {
if (piece.trim().isEmpty) continue;
if (isSdFatalLine(piece)) fatal.add(piece.trim());
final prog = parseSdProgress(piece);
if (prog != null) {
onProgress?.call(prog);
} else {
tail.add(piece.trim());
if (tail.length > 8) tail.removeAt(0);
}
}
}
final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture<void>();
final code = await proc.exitCode;
await Future.wait([a, b]);
// 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다
if (fatal.isNotEmpty) {
throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n'));
}
// 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다
if (code != 0 && !saved) {
final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList();
// 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에
throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n'));
}
return tail;
}
/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판,
/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh)
String sdCliLibName(String cpuinfo) {
final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l];
bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f);
final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp'));
return v82 ? 'libsdcli.so' : 'libsdcli_v80.so';
}
/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다
String sdExitMessage(int code) => switch (code) {
-4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령
-9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'),
_ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]),
};

View File

@ -10,6 +10,7 @@ import '../core/sd_cli.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../services/image_ai.dart'; import '../services/image_ai.dart';
import 'ai_local.dart'; import 'ai_local.dart';
import 'ai_local_engine.dart';
/// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale). /// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale).
/// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다. /// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다.

View File

@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../../app/app_controller.dart'; import '../../app/app_controller.dart';
import '../../app/i18n_controller.dart'; import '../../app/i18n_controller.dart';
import '../../app/ai_jobs.dart';
import '../../app/ai_local.dart'; import '../../app/ai_local.dart';
import '../../app/download_manager.dart'; import '../../app/download_manager.dart';
import '../../app/copy_center.dart'; import '../../app/copy_center.dart';

View File

@ -5,7 +5,9 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../app/ai_jobs.dart';
import '../app/ai_local.dart'; import '../app/ai_local.dart';
import '../app/ai_local_engine.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/reader_sources.dart'; import '../core/reader_sources.dart';
import '../core/sd_cli.dart'; import '../core/sd_cli.dart';

View File

@ -2,7 +2,9 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/ai_jobs.dart';
import '../app/ai_local.dart'; import '../app/ai_local.dart';
import '../app/ai_local_engine.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../app/copy_center.dart' show diskSpace; import '../app/copy_center.dart' show diskSpace;
import '../core/ai_catalog.dart'; import '../core/ai_catalog.dart';

View File

@ -6,7 +6,9 @@ import 'package:flutter/services.dart' show MethodChannel;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../app/ai_jobs.dart';
import '../app/ai_local.dart'; import '../app/ai_local.dart';
import '../app/ai_local_engine.dart';
import '../app/ai_upscale.dart'; import '../app/ai_upscale.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/ai_catalog.dart'; import '../core/ai_catalog.dart';

View File

@ -0,0 +1,395 @@
// 즐겨찾기 관리자 화면 (Chrome 의 chrome://bookmarks 참고) - bookmark_ui.dart 에서 나눔.
// 약속:
// - 입력: 즐겨찾기 [BookmarksController] · 열기 콜백 ([onOpen] · [onOpenExternal]) · 지금 페이지 주소 · 제목 · 처음 열 폴더.
// - 출력: 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 결과. 바꾸기 (추가 · 수정 · 삭제 · 옮기기 · 순서) 는 모두 [BookmarksController] 로,
// HTML 내보내기 · 가져오기 · 다른 브라우저에서 가져오기 결과는 알림 한 줄.
// - 오류: 가져올 것이 없거나 파일을 찾지 못하면 알림 글로만 (던지지 않음). 고른 폴더가 없어지면 즐겨찾기 줄로 돌아간다.
// 대화 상자 · 메뉴 · 끌기 조각은 bookmark_ui.dart 의 것을 부른다.
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../app/bookmarks_controller.dart';
import '../core/bookmarks.dart';
import '../l10n/tr.dart';
import 'app_actions.dart';
import 'bookmark_ui.dart';
import 'theme.dart';
class BookmarkManagerPage extends StatefulWidget {
final BookmarksController bm;
final void Function(String url) onOpen;
final void Function(String url)? onOpenExternal;
final String currentUrl;
final String currentTitle;
final String? initialFolderId;
const BookmarkManagerPage({
super.key,
required this.bm,
required this.onOpen,
this.onOpenExternal,
this.currentUrl = '',
this.currentTitle = '',
this.initialFolderId,
});
@override
State<BookmarkManagerPage> createState() => _BookmarkManagerPageState();
}
class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
late String _folder = widget.initialFolderId ?? BookmarkTree.barId;
final _search = TextEditingController();
final Set<String> _collapsed = {};
BookmarksController get bm => widget.bm;
@override
void dispose() {
_search.dispose();
super.dispose();
}
void _open(String url) {
widget.onOpen(url);
Navigator.maybePop(context);
}
Future<void> _export() async {
final uri = await FilePicker.saveFile(
dialogTitle: tr('즐겨찾기 내보내기 (HTML)'),
fileName: tr('JJ_MKVMaker_즐겨찾기.html'),
bytes: Uint8List.fromList(utf8.encode(bm.tree.toNetscapeHtml())),
type: FileType.custom,
allowedExtensions: const ['html'],
);
if (uri == null) return;
final file = uri.scheme == 'file' ? uri.toFilePath() : uri.toString();
await bm.exportHtml(file);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('내보냈습니다: {0}', [file]))));
}
}
Future<void> _importHtml() async {
final r = await FilePicker.pickFiles(
dialogTitle: tr('즐겨찾기 HTML 가져오기'), type: FileType.custom, allowedExtensions: const ['html', 'htm']);
final path = r.isEmpty ? null : r.single.path;
if (path == null) return;
final n = await bm.importHtml(path);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(n > 0 ? trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]) : tr('가져올 즐겨찾기가 없습니다.'))));
if (n > 0) setState(() => _folder = BookmarkTree.otherId);
}
Future<void> _importBrowser(String b) async {
final n = await bm.importFrom(b);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n < 0 ? tr('이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.') : trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]))));
if (n > 0) setState(() => _folder = BookmarkTree.otherId);
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: bm,
builder: (context, _) {
if (bm.tree.find(_folder) == null) _folder = BookmarkTree.barId;
final q = _search.text.trim();
return Scaffold(
body: Column(children: [
_topBar(),
const Divider(height: 1),
Expanded(
child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SizedBox(width: 280, child: _tree()),
const VerticalDivider(width: 1),
Expanded(child: q.isEmpty ? _contents() : _results(q)),
]),
),
]),
);
},
);
Widget _topBar() => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
middle: Row(children: [
Icon(Icons.bookmarks_outlined, color: JjColors.accent),
const SizedBox(width: 8),
if (!isCompact(context)) ...[
Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 24),
],
Expanded(
child: TextField(
controller: _search,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: JjColors.bg,
hintText: tr('즐겨찾기 검색 (이름 · 주소)'),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _search.text.isEmpty
? null
: IconButton(
tooltip: tr('검색 지우기'),
icon: const Icon(Icons.close, size: 16),
onPressed: () => setState(_search.clear)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
tooltip: tr('관리 메뉴'),
icon: const Icon(Icons.more_vert),
itemBuilder: (_) => [
PopupMenuItem(value: 'link', child: Text(tr('새 즐겨찾기 추가'))),
PopupMenuItem(value: 'folder', child: Text(tr('새 폴더 추가'))),
PopupMenuDivider(),
PopupMenuItem(value: 'chrome', child: Text(tr('Chrome 에서 가져오기'))),
PopupMenuItem(value: 'edge', child: Text(tr('Edge 에서 가져오기'))),
PopupMenuItem(value: 'whale', child: Text(tr('Whale 에서 가져오기'))),
PopupMenuItem(value: 'html', child: Text(tr('HTML 파일에서 가져오기'))),
PopupMenuDivider(),
PopupMenuItem(value: 'export', child: Text(tr('HTML 파일로 내보내기'))),
],
onSelected: (v) async {
switch (v) {
case 'link':
await showNewBookmarkDialog(context, bm, _folder, title: widget.currentTitle, url: widget.currentUrl);
case 'folder':
await showNewFolderDialog(context, bm, _folder);
case 'chrome' || 'edge' || 'whale':
await _importBrowser(v);
case 'html':
await _importHtml();
case 'export':
await _export();
}
},
),
]),
);
// 왼쪽: 폴더 나무 (누르면 오른쪽에 내용, 즐겨찾기를 끌어다 놓으면 그 폴더로)
Widget _tree() {
final rows = <Widget>[];
void walk(BookmarkNode f, int depth) {
final subs = f.children!.where((c) => c.isFolder).toList();
final open = !_collapsed.contains(f.id);
final selected = f.id == _folder;
void folderMenu(Offset at) => showBookmarkMenu(context,
bm: bm,
globalPosition: at,
n: f,
folderId: f.id,
onOpen: _open,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
final row = BookmarkFolderDrop(
bm: bm,
folderId: f.id,
// 터치 화면: 길게 누르면 오른쪽 클릭과 같은 메뉴
child: GestureDetector(
onLongPressStart: depth == 0 ? (d) => folderMenu(d.globalPosition) : null,
child: InkWell(
onTap: () => setState(() {
_folder = f.id;
_search.clear();
}),
onSecondaryTapDown: (d) => folderMenu(d.globalPosition),
child: Container(
color: selected ? JjColors.accent.withValues(alpha: 0.16) : null,
padding: EdgeInsets.only(left: 6 + depth * 16.0, right: 8, top: 6, bottom: 6),
child: Row(children: [
SizedBox(
width: 22,
child: subs.isEmpty
? null
: InkWell(
onTap: () => setState(() => open ? _collapsed.add(f.id) : _collapsed.remove(f.id)),
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18),
),
),
Icon(depth == 0 ? Icons.folder_special : (selected ? Icons.folder_open : Icons.folder),
size: 18, color: JjColors.amber),
const SizedBox(width: 8),
Expanded(child: Text(f.title, maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
),
),
),
);
rows.add(depth == 0 ? row : BookmarkDraggable(n: f, onMenu: folderMenu, child: row));
if (open) {
for (final s in subs) {
walk(s, depth + 1);
}
}
}
for (final r in bm.tree.roots) {
walk(r, 0);
}
return Material(color: JjColors.panel, child: ListView(children: rows));
}
// 오른쪽: 고른 폴더의 내용 (끌어서 순서 바꾸기, 폴더 위로 끌면 그 안으로, 오른쪽 클릭 메뉴)
Widget _contents() {
final folder = bm.tree.find(_folder)!;
final items = folder.children!;
final path = bm.tree.pathTo(folder.id);
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 6),
child: Row(children: [
Expanded(
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, f) in path.indexed) ...[
if (i > 0) Icon(Icons.chevron_right, size: 16, color: JjColors.textDim),
BookmarkFolderDrop(
bm: bm,
folderId: f.id,
child: InkWell(
onTap: () => setState(() => _folder = f.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Text(f.title,
style: TextStyle(
fontSize: 14,
fontWeight: f.id == folder.id ? FontWeight.w600 : FontWeight.normal,
color: f.id == folder.id ? JjColors.text : JjColors.accent)),
),
),
),
],
]),
),
OutlinedButton.icon(
onPressed: () => showNewBookmarkDialog(context, bm, folder.id,
title: widget.currentTitle, url: widget.currentUrl),
icon: const Icon(Icons.add_link, size: 18),
label: Text(tr('즐겨찾기 추가')),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showNewFolderDialog(context, bm, folder.id),
icon: const Icon(Icons.create_new_folder_outlined, size: 18),
label: Text(tr('새 폴더')),
),
]),
),
const Divider(height: 1),
Expanded(
child: items.isEmpty
? Center(
child: Text(tr('비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.'),
textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)))
: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: items.length,
onReorderItem: (o, n) => bm.reorder(folder.id, o, n),
itemBuilder: (_, i) => _row(items[i], i, folder),
),
),
Padding(
padding: EdgeInsets.all(8),
child: Text(tr('왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴'),
textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
),
]);
}
Widget _row(BookmarkNode n, int i, BookmarkNode folder) {
final tile = ListTile(
dense: true,
leading: Row(mainAxisSize: MainAxisSize.min, children: [
ReorderableDragStartListener(
index: i,
child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim),
),
const SizedBox(width: 6),
// 아이콘을 끌면 다른 폴더로 옮기기
BookmarkDraggable(
n: n,
onMenu: (at) => _menu(n, folder, at),
child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
]),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(n.isFolder ? trf('{0}개 · 주소 {1}개', [n.children!.length, bm.tree.countLinks(n)]) : n.url!,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder ? setState(() => _folder = n.id) : _open(n.url!),
trailing: IconButton(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, size: 18),
onPressed: () {
final box = context.findRenderObject() as RenderBox?;
final pos = box == null ? Offset.zero : box.localToGlobal(box.size.topRight(Offset.zero));
_menu(n, folder, pos);
},
),
);
final row = GestureDetector(
onSecondaryTapDown: (d) => _menu(n, folder, d.globalPosition),
child: n.isFolder ? BookmarkFolderDrop(bm: bm, folderId: n.id, child: tile) : tile,
);
return KeyedSubtree(key: ValueKey(n.id), child: row);
}
void _menu(BookmarkNode n, BookmarkNode folder, Offset pos) => showBookmarkMenu(context,
bm: bm,
globalPosition: pos,
n: n,
folderId: folder.id,
onOpen: _open,
onOpenExternal: widget.onOpenExternal,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
// 검색 결과 (위치 = 들어 있는 폴더)
Widget _results(String q) {
final found = bm.tree.search(q);
if (found.isEmpty) return Center(child: Text(tr('찾는 즐겨찾기가 없습니다.'), style: TextStyle(color: JjColors.textDim)));
return ListView(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Text(trf('"{0}" 검색 결과 {1}개', [q, found.length]), style: const TextStyle(fontWeight: FontWeight.w600)),
),
for (final n in found)
GestureDetector(
onSecondaryTapDown: (d) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, d.globalPosition),
child: ListTile(
dense: true,
leading: BookmarkDraggable(
n: n,
onMenu: (at) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, at),
child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'${bm.tree.pathTo(n.id).reversed.skip(1).toList().reversed.map((f) => f.title).join(' › ')}'
'${n.isFolder ? '' : ' · ${n.url}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder
? setState(() {
_folder = n.id;
_search.clear();
})
: _open(n.url!),
),
),
]);
}
}

View File

@ -1,21 +1,19 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/bookmarks_controller.dart'; import '../app/bookmarks_controller.dart';
import '../core/bookmarks.dart'; import '../core/bookmarks.dart';
import 'app_actions.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'notice.dart'; import 'notice.dart';
// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고): // 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고). 관리자 화면은 bookmark_manager_page.dart (이 파일에서 나눔).
// - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더) // - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더)
// - 오른쪽 클릭 메뉴: 열기 · 수정 · 삭제 · 이 폴더에 페이지 / 폴더 추가 · 관리자 // - 오른쪽 클릭 메뉴: 열기 · 수정 · 삭제 · 이 폴더에 페이지 / 폴더 추가 · 관리자
// - 끌어다 놓기: 즐겨찾기 · 폴더를 폴더 위에 놓으면 그 폴더 안으로 // - 끌어다 놓기: 즐겨찾기 · 폴더를 폴더 위에 놓으면 그 폴더 안으로
// - 즐겨찾기 관리자 (전체 화면): 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 · HTML 내보내기 / 가져오기 // 약속:
// - 입력: [BuildContext] · 즐겨찾기 [BookmarksController] · 대상 [BookmarkNode] / 폴더 id · 열기 콜백.
// - 출력: 바꾸기는 모두 [BookmarksController] 로 (삭제는 "실행 취소" 알림과 함께), 묻기 창은 고른 값 (취소하면 null).
// - 오류: 던지지 않는다. 창이 닫힌 뒤 context 가 없어졌으면 아무것도 하지 않는다.
/// 끌고 다니는 즐겨찾기 (id) /// 끌고 다니는 즐겨찾기 (id)
class BookmarkDrag { class BookmarkDrag {
@ -402,382 +400,3 @@ class _BookmarkDraggableState extends State<BookmarkDraggable> {
); );
} }
} }
// ───────── 즐겨찾기 관리자 (Chrome 의 chrome://bookmarks 참고) ─────────
class BookmarkManagerPage extends StatefulWidget {
final BookmarksController bm;
final void Function(String url) onOpen;
final void Function(String url)? onOpenExternal;
final String currentUrl;
final String currentTitle;
final String? initialFolderId;
const BookmarkManagerPage({
super.key,
required this.bm,
required this.onOpen,
this.onOpenExternal,
this.currentUrl = '',
this.currentTitle = '',
this.initialFolderId,
});
@override
State<BookmarkManagerPage> createState() => _BookmarkManagerPageState();
}
class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
late String _folder = widget.initialFolderId ?? BookmarkTree.barId;
final _search = TextEditingController();
final Set<String> _collapsed = {};
BookmarksController get bm => widget.bm;
@override
void dispose() {
_search.dispose();
super.dispose();
}
void _open(String url) {
widget.onOpen(url);
Navigator.maybePop(context);
}
Future<void> _export() async {
final uri = await FilePicker.saveFile(
dialogTitle: tr('즐겨찾기 내보내기 (HTML)'),
fileName: tr('JJ_MKVMaker_즐겨찾기.html'),
bytes: Uint8List.fromList(utf8.encode(bm.tree.toNetscapeHtml())),
type: FileType.custom,
allowedExtensions: const ['html'],
);
if (uri == null) return;
final file = uri.scheme == 'file' ? uri.toFilePath() : uri.toString();
await bm.exportHtml(file);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('내보냈습니다: {0}', [file]))));
}
}
Future<void> _importHtml() async {
final r = await FilePicker.pickFiles(
dialogTitle: tr('즐겨찾기 HTML 가져오기'), type: FileType.custom, allowedExtensions: const ['html', 'htm']);
final path = r.isEmpty ? null : r.single.path;
if (path == null) return;
final n = await bm.importHtml(path);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(n > 0 ? trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]) : tr('가져올 즐겨찾기가 없습니다.'))));
if (n > 0) setState(() => _folder = BookmarkTree.otherId);
}
Future<void> _importBrowser(String b) async {
final n = await bm.importFrom(b);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n < 0 ? tr('이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.') : trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]))));
if (n > 0) setState(() => _folder = BookmarkTree.otherId);
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: bm,
builder: (context, _) {
if (bm.tree.find(_folder) == null) _folder = BookmarkTree.barId;
final q = _search.text.trim();
return Scaffold(
body: Column(children: [
_topBar(),
const Divider(height: 1),
Expanded(
child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SizedBox(width: 280, child: _tree()),
const VerticalDivider(width: 1),
Expanded(child: q.isEmpty ? _contents() : _results(q)),
]),
),
]),
);
},
);
Widget _topBar() => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
middle: Row(children: [
Icon(Icons.bookmarks_outlined, color: JjColors.accent),
const SizedBox(width: 8),
if (!isCompact(context)) ...[
Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 24),
],
Expanded(
child: TextField(
controller: _search,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: JjColors.bg,
hintText: tr('즐겨찾기 검색 (이름 · 주소)'),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _search.text.isEmpty
? null
: IconButton(
tooltip: tr('검색 지우기'),
icon: const Icon(Icons.close, size: 16),
onPressed: () => setState(_search.clear)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
tooltip: tr('관리 메뉴'),
icon: const Icon(Icons.more_vert),
itemBuilder: (_) => [
PopupMenuItem(value: 'link', child: Text(tr('새 즐겨찾기 추가'))),
PopupMenuItem(value: 'folder', child: Text(tr('새 폴더 추가'))),
PopupMenuDivider(),
PopupMenuItem(value: 'chrome', child: Text(tr('Chrome 에서 가져오기'))),
PopupMenuItem(value: 'edge', child: Text(tr('Edge 에서 가져오기'))),
PopupMenuItem(value: 'whale', child: Text(tr('Whale 에서 가져오기'))),
PopupMenuItem(value: 'html', child: Text(tr('HTML 파일에서 가져오기'))),
PopupMenuDivider(),
PopupMenuItem(value: 'export', child: Text(tr('HTML 파일로 내보내기'))),
],
onSelected: (v) async {
switch (v) {
case 'link':
await showNewBookmarkDialog(context, bm, _folder, title: widget.currentTitle, url: widget.currentUrl);
case 'folder':
await showNewFolderDialog(context, bm, _folder);
case 'chrome' || 'edge' || 'whale':
await _importBrowser(v);
case 'html':
await _importHtml();
case 'export':
await _export();
}
},
),
]),
);
// 왼쪽: 폴더 나무 (누르면 오른쪽에 내용, 즐겨찾기를 끌어다 놓으면 그 폴더로)
Widget _tree() {
final rows = <Widget>[];
void walk(BookmarkNode f, int depth) {
final subs = f.children!.where((c) => c.isFolder).toList();
final open = !_collapsed.contains(f.id);
final selected = f.id == _folder;
void folderMenu(Offset at) => showBookmarkMenu(context,
bm: bm,
globalPosition: at,
n: f,
folderId: f.id,
onOpen: _open,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
final row = BookmarkFolderDrop(
bm: bm,
folderId: f.id,
// 터치 화면: 길게 누르면 오른쪽 클릭과 같은 메뉴
child: GestureDetector(
onLongPressStart: depth == 0 ? (d) => folderMenu(d.globalPosition) : null,
child: InkWell(
onTap: () => setState(() {
_folder = f.id;
_search.clear();
}),
onSecondaryTapDown: (d) => folderMenu(d.globalPosition),
child: Container(
color: selected ? JjColors.accent.withValues(alpha: 0.16) : null,
padding: EdgeInsets.only(left: 6 + depth * 16.0, right: 8, top: 6, bottom: 6),
child: Row(children: [
SizedBox(
width: 22,
child: subs.isEmpty
? null
: InkWell(
onTap: () => setState(() => open ? _collapsed.add(f.id) : _collapsed.remove(f.id)),
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18),
),
),
Icon(depth == 0 ? Icons.folder_special : (selected ? Icons.folder_open : Icons.folder),
size: 18, color: JjColors.amber),
const SizedBox(width: 8),
Expanded(child: Text(f.title, maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
),
),
),
);
rows.add(depth == 0 ? row : BookmarkDraggable(n: f, onMenu: folderMenu, child: row));
if (open) {
for (final s in subs) {
walk(s, depth + 1);
}
}
}
for (final r in bm.tree.roots) {
walk(r, 0);
}
return Material(color: JjColors.panel, child: ListView(children: rows));
}
// 오른쪽: 고른 폴더의 내용 (끌어서 순서 바꾸기, 폴더 위로 끌면 그 안으로, 오른쪽 클릭 메뉴)
Widget _contents() {
final folder = bm.tree.find(_folder)!;
final items = folder.children!;
final path = bm.tree.pathTo(folder.id);
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 6),
child: Row(children: [
Expanded(
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, f) in path.indexed) ...[
if (i > 0) Icon(Icons.chevron_right, size: 16, color: JjColors.textDim),
BookmarkFolderDrop(
bm: bm,
folderId: f.id,
child: InkWell(
onTap: () => setState(() => _folder = f.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Text(f.title,
style: TextStyle(
fontSize: 14,
fontWeight: f.id == folder.id ? FontWeight.w600 : FontWeight.normal,
color: f.id == folder.id ? JjColors.text : JjColors.accent)),
),
),
),
],
]),
),
OutlinedButton.icon(
onPressed: () => showNewBookmarkDialog(context, bm, folder.id,
title: widget.currentTitle, url: widget.currentUrl),
icon: const Icon(Icons.add_link, size: 18),
label: Text(tr('즐겨찾기 추가')),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showNewFolderDialog(context, bm, folder.id),
icon: const Icon(Icons.create_new_folder_outlined, size: 18),
label: Text(tr('새 폴더')),
),
]),
),
const Divider(height: 1),
Expanded(
child: items.isEmpty
? Center(
child: Text(tr('비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.'),
textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)))
: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: items.length,
onReorderItem: (o, n) => bm.reorder(folder.id, o, n),
itemBuilder: (_, i) => _row(items[i], i, folder),
),
),
Padding(
padding: EdgeInsets.all(8),
child: Text(tr('왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴'),
textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
),
]);
}
Widget _row(BookmarkNode n, int i, BookmarkNode folder) {
final tile = ListTile(
dense: true,
leading: Row(mainAxisSize: MainAxisSize.min, children: [
ReorderableDragStartListener(
index: i,
child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim),
),
const SizedBox(width: 6),
// 아이콘을 끌면 다른 폴더로 옮기기
BookmarkDraggable(
n: n,
onMenu: (at) => _menu(n, folder, at),
child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
]),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(n.isFolder ? trf('{0}개 · 주소 {1}개', [n.children!.length, bm.tree.countLinks(n)]) : n.url!,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder ? setState(() => _folder = n.id) : _open(n.url!),
trailing: IconButton(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, size: 18),
onPressed: () {
final box = context.findRenderObject() as RenderBox?;
final pos = box == null ? Offset.zero : box.localToGlobal(box.size.topRight(Offset.zero));
_menu(n, folder, pos);
},
),
);
final row = GestureDetector(
onSecondaryTapDown: (d) => _menu(n, folder, d.globalPosition),
child: n.isFolder ? BookmarkFolderDrop(bm: bm, folderId: n.id, child: tile) : tile,
);
return KeyedSubtree(key: ValueKey(n.id), child: row);
}
void _menu(BookmarkNode n, BookmarkNode folder, Offset pos) => showBookmarkMenu(context,
bm: bm,
globalPosition: pos,
n: n,
folderId: folder.id,
onOpen: _open,
onOpenExternal: widget.onOpenExternal,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
// 검색 결과 (위치 = 들어 있는 폴더)
Widget _results(String q) {
final found = bm.tree.search(q);
if (found.isEmpty) return Center(child: Text(tr('찾는 즐겨찾기가 없습니다.'), style: TextStyle(color: JjColors.textDim)));
return ListView(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Text(trf('"{0}" 검색 결과 {1}개', [q, found.length]), style: const TextStyle(fontWeight: FontWeight.w600)),
),
for (final n in found)
GestureDetector(
onSecondaryTapDown: (d) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, d.globalPosition),
child: ListTile(
dense: true,
leading: BookmarkDraggable(
n: n,
onMenu: (at) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, at),
child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'${bm.tree.pathTo(n.id).reversed.skip(1).toList().reversed.map((f) => f.title).join(' › ')}'
'${n.isFolder ? '' : ' · ${n.url}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder
? setState(() {
_folder = n.id;
_search.clear();
})
: _open(n.url!),
),
),
]);
}
}

View File

@ -25,6 +25,7 @@ import '../platform/windows/com_guard.dart';
import '../platform/windows/elevation.dart'; import '../platform/windows/elevation.dart';
import '../services/downloader.dart' show DownloadState; import '../services/downloader.dart' show DownloadState;
import 'app_actions.dart'; import 'app_actions.dart';
import 'bookmark_manager_page.dart';
import 'bookmark_ui.dart'; import 'bookmark_ui.dart';
import 'cef_setup.dart' show confirmCefWhenElevated; import 'cef_setup.dart' show confirmCefWhenElevated;
import 'confirm.dart'; import 'confirm.dart';

View File

@ -1,3 +1,5 @@
// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다.
// 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다.
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -11,8 +13,8 @@ import '../core/srt.dart';
import '../core/text_codec.dart'; import '../core/text_codec.dart';
import '../services/preview_player.dart'; import '../services/preview_player.dart';
import 'app_actions.dart'; import 'app_actions.dart';
import 'key_hint.dart';
import 'path_label.dart' show middleEllipsis; import 'path_label.dart' show middleEllipsis;
import 'subtitle_editor_panes.dart';
import 'theme.dart'; import 'theme.dart';
import 'timeline.dart'; import 'timeline.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -317,172 +319,31 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
); );
} }
// ───────── 영상 · 자막 겹쳐 보기 ───────── // ───────── 화면 조각 (subtitle_editor_panes.dart) ─────────
Widget _videoPane() => Container( Widget _videoPane() => SubtitleVideoPane(view: _pl!.buildView(), editor: e, position: _position, error: _playerError);
color: Colors.black,
child: Stack( Widget _cueTable() => SubtitleCueTable(editor: e, active: _active, onSelect: _selectCue);
fit: StackFit.expand,
children: [ Widget _transportBar() => SubtitleTransportBar(
_pl!.buildView(), position: _position,
// 편집 중인 자막을 영상 위에 표시 duration: _duration,
Positioned( playing: _playing,
left: 16, rate: _rate,
right: 16, hasSelection: e.selected != null,
bottom: 20, onSeekBy: _seekBy,
child: IgnorePointer( onPlayPause: () => _pl?.playOrPause(),
child: ValueListenableBuilder<Duration>( onRate: (r) {
valueListenable: _position, setState(() => _rate = r);
builder: (_, pos, _) => ListenableBuilder( _pl?.setRate(r);
listenable: e, },
builder: (_, _) { onInsert: _insertHere,
final text = e.activeAt(pos).map((c) => c.text).join('\n'); onPlaySelected: _playSelected,
if (text.isEmpty) return const SizedBox(); onSetStart: _setStartHere,
return Center( onSetEnd: _setEndHere,
child: Container( onAlign: _alignHere,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
color: Colors.black.withValues(alpha: 0.55),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20, color: Colors.white, height: 1.3),
),
),
);
},
),
),
),
),
if (_playerError != null)
Positioned(
left: 8,
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(8),
color: JjColors.danger.withValues(alpha: 0.85),
child: Text(trf('영상 재생 오류: {0}', [_playerError]),
style: const TextStyle(fontSize: 12, color: Colors.white)),
),
),
],
),
); );
Widget _cueTable() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _HeaderRow(),
const Divider(height: 1),
Expanded(
child: ValueListenableBuilder<Cue?>(
valueListenable: _active,
builder: (_, active, _) => ListView.builder(
itemCount: e.cues.length,
itemBuilder: (_, i) {
final c = e.cues[i];
return _CueRow(
key: ObjectKey(c),
index: i,
cue: c,
editor: e,
orderError: e.invalidOrder.contains(i),
selected: e.selected == c,
active: active == c,
onSelect: () => _selectCue(c),
);
},
),
),
),
],
);
// ───────── 재생 조작 막대 ─────────
Widget _transportBar() {
final hasSel = e.selected != null;
Widget btn(String label, String key, VoidCallback? onTap) => Padding(
padding: const EdgeInsets.only(left: 6),
child: Tooltip(
message: key,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10),
visualDensity: VisualDensity.compact),
onPressed: onTap,
child: Text(label, style: const TextStyle(fontSize: 12)),
),
),
);
return Container(
height: 48,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
tooltip: tr('5초 뒤로'),
icon: const Icon(Icons.replay_5),
onPressed: () => _seekBy(-5000),
),
IconButton(
tooltip: keyHint(tr('재생/정지'), 'Ctrl+Space'),
iconSize: 30,
icon: Icon(_playing ? Icons.pause_circle : Icons.play_circle,
color: JjColors.accent),
onPressed: () => _pl?.playOrPause(),
),
IconButton(
tooltip: tr('5초 앞으로'),
icon: const Icon(Icons.forward_5),
onPressed: () => _seekBy(5000),
),
const SizedBox(width: 6),
ValueListenableBuilder<Duration>(
valueListenable: _position,
builder: (_, pos, _) => Text(
'${formatSrtTime(pos)} / ${formatSrtTime(_duration)}',
style: const TextStyle(fontFamily: 'Consolas', fontSize: 13),
),
),
const SizedBox(width: 10),
DropdownButton<double>(
value: _rate,
isDense: true,
underline: const SizedBox(),
style: TextStyle(fontSize: 12, color: JjColors.text),
items: [
for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0])
DropdownMenuItem(value: r, child: Text('${r}x')),
],
onChanged: (r) {
setState(() => _rate = r!);
_pl?.setRate(r!);
},
),
// 폭이 좁으면 (휴대폰) 밀어서 본다. 넓으면 지금처럼 오른쪽에 붙는다 (reverse)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(mainAxisSize: MainAxisSize.min, children: [
btn(tr('+ 여기에 새 줄'), 'F7', _insertHere),
btn(tr('선택 줄 재생'), 'F8', hasSel ? _playSelected : null),
btn(tr('시작 = 현재'), 'F9', hasSel ? _setStartHere : null),
btn(tr('끝 = 현재'), 'F10', hasSel ? _setEndHere : null),
btn(tr('선택 줄부터 여기로 맞추기'), tr('선택한 줄과 그 뒤 모든 줄을 함께 이동'), hasSel ? _alignHere : null),
]),
),
),
],
),
);
}
Widget _toolbar(BuildContext context) => AppTopBar( Widget _toolbar(BuildContext context) => AppTopBar(
nav: const AppNavButtons(), nav: const AppNavButtons(),
actions: const AppActions(), actions: const AppActions(),
@ -584,215 +445,3 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
} }
} }
class _HeaderRow extends StatelessWidget {
const _HeaderRow();
@override
Widget build(BuildContext context) => Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: LayoutBuilder(builder: (context, box) {
if (!_cueNarrow(box.maxWidth)) {
return Row(children: [
SizedBox(width: 48, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
Expanded(child: Text(tr('내용'), style: _hdr)),
SizedBox(width: 96),
]);
}
// F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
SizedBox(width: _narrowNo, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)),
]);
}),
);
}
/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서
/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움)
bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200;
/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368)
const _narrowNo = 28.0;
const _narrowButton = 40.0;
final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
class _CueRow extends StatefulWidget {
final int index;
final Cue cue;
final SubtitleEditorController editor;
final bool orderError;
final bool selected;
/// 현재 재생 위치에서 표시 중인 줄
final bool active;
final VoidCallback? onSelect;
const _CueRow({
super.key,
required this.index,
required this.cue,
required this.editor,
required this.orderError,
this.selected = false,
this.active = false,
this.onSelect,
});
@override
State<_CueRow> createState() => _CueRowState();
}
class _CueRowState extends State<_CueRow> {
late final _start = TextEditingController(text: formatSrtTime(widget.cue.start));
late final _end = TextEditingController(text: formatSrtTime(widget.cue.end));
late final _text = TextEditingController(text: widget.cue.text);
final _startFocus = FocusNode();
final _endFocus = FocusNode();
bool _startBad = false, _endBad = false;
@override
void didUpdateWidget(covariant _CueRow old) {
super.didUpdateWidget(old);
// 전체 이동 등 바깥에서 바뀐 시간을 입력칸에 반영 (입력 중이 아닐 때만)
if (!_startFocus.hasFocus && !_startBad) _sync(_start, formatSrtTime(widget.cue.start));
if (!_endFocus.hasFocus && !_endBad) _sync(_end, formatSrtTime(widget.cue.end));
}
void _sync(TextEditingController c, String v) {
if (c.text != v) c.text = v;
}
@override
void dispose() {
_start.dispose();
_end.dispose();
_text.dispose();
_startFocus.dispose();
_endFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final e = widget.editor;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: widget.orderError
? JjColors.danger.withValues(alpha: 0.08)
: widget.selected
? JjColors.accent.withValues(alpha: 0.12)
: null,
border: Border(
bottom: BorderSide(color: JjColors.border),
left: BorderSide(
color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
),
),
child: LayoutBuilder(builder: (context, box) {
final narrow = _cueNarrow(box.maxWidth);
final number = SizedBox(
width: narrow ? _narrowNo : 48,
child: InkWell(
onTap: widget.onSelect,
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: Text('${widget.index + 1}',
style: TextStyle(
fontSize: 12,
color: widget.selected ? JjColors.accent : JjColors.textDim)),
),
),
);
final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true));
final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false));
final text = TextField(
controller: _text,
minLines: 1,
maxLines: 4,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(isDense: true, border: InputBorder.none),
onTap: () => e.select(widget.cue),
onChanged: (t) => e.setText(widget.cue, t),
);
// 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게
final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null;
final buttons = [
IconButton(
tooltip: tr('아래에 줄 추가'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index),
),
IconButton(
tooltip: tr('줄 삭제'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index),
),
];
if (!narrow) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
number,
start,
end,
Expanded(child: text),
SizedBox(width: 96, child: Row(children: buttons)),
]);
}
// F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text),
]);
}),
);
}
Widget _timeField(
TextEditingController c, FocusNode f, bool bad, bool isStart) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: TextField(
controller: c,
focusNode: f,
style: const TextStyle(fontSize: 13, fontFamily: 'Consolas'),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
errorText: bad ? tr('형식 오류') : null,
errorStyle: const TextStyle(fontSize: 10),
),
onTap: () => widget.editor.select(widget.cue),
onChanged: (t) {
final ok = widget.editor.setTime(widget.cue, t, isStart: isStart);
setState(() {
if (isStart) {
_startBad = !ok;
} else {
_endBad = !ok;
}
});
},
onEditingComplete: () {
// 입력을 마치면 표준 형식으로 정리
final d = parseSrtTime(c.text);
if (d != null) c.text = formatSrtTime(d);
f.unfocus();
},
),
);
}
}

View File

@ -0,0 +1,437 @@
// 자막 편집 화면의 조각 (영상 위 자막 · 자막 표 · 재생 조작 막대) - subtitle_editor_page.dart 에서 나눔.
// 약속:
// - 입력: 자막 편집기 [SubtitleEditorController], 재생 위치 · 지금 보이는 줄 ([ValueListenable]), 재생기 화면 · 오류 글,
// 재생 상태 (길이 · 재생 중 · 빠르기 · 고른 줄이 있는지) 와 누를 때 부를 콜백.
// - 출력: 화면 조각. 고치기 (시간 · 내용 · 줄 추가 · 삭제) 는 [SubtitleEditorController] 로, 재생 조작은 콜백으로만.
// - 오류: 던지지 않는다. 시간 형식이 틀리면 그 칸에 "형식 오류" 만 보인다.
// 화면 (State) 의 필드에 기대지 않는다 - 다른 화면에서도 같은 인자로 부를 수 있다.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../app/subtitle_editor_controller.dart';
import '../core/srt.dart';
import '../l10n/tr.dart';
import 'key_hint.dart';
import 'theme.dart';
/// 영상 위에 편집 중인 자막 (지금 위치의 줄) · 재생 오류를 겹쳐 보인다
class SubtitleVideoPane extends StatelessWidget {
/// 재생기 화면 (PreviewPlayer.buildView)
final Widget view;
final SubtitleEditorController editor;
final ValueListenable<Duration> position;
final String? error;
const SubtitleVideoPane({super.key, required this.view, required this.editor, required this.position, this.error});
@override
Widget build(BuildContext context) => Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
view,
// 편집 중인 자막을 영상 위에 표시
Positioned(
left: 16,
right: 16,
bottom: 20,
child: IgnorePointer(
child: ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (_, pos, _) => ListenableBuilder(
listenable: editor,
builder: (_, _) {
final text = editor.activeAt(pos).map((c) => c.text).join('\n');
if (text.isEmpty) return const SizedBox();
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
color: Colors.black.withValues(alpha: 0.55),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20, color: Colors.white, height: 1.3),
),
),
);
},
),
),
),
),
if (error != null)
Positioned(
left: 8,
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(8),
color: JjColors.danger.withValues(alpha: 0.85),
child: Text(trf('영상 재생 오류: {0}', [error]),
style: const TextStyle(fontSize: 12, color: Colors.white)),
),
),
],
),
);
}
/// 자막 표 (머리 줄 + 줄마다 시작 · 끝 · 내용 · 단추). [active] 는 지금 위치에서 보이는 줄
class SubtitleCueTable extends StatelessWidget {
final SubtitleEditorController editor;
final ValueListenable<Cue?> active;
final void Function(Cue c) onSelect;
const SubtitleCueTable({super.key, required this.editor, required this.active, required this.onSelect});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _HeaderRow(),
const Divider(height: 1),
Expanded(
child: ValueListenableBuilder<Cue?>(
valueListenable: active,
builder: (_, active, _) => ListView.builder(
itemCount: editor.cues.length,
itemBuilder: (_, i) {
final c = editor.cues[i];
return _CueRow(
key: ObjectKey(c),
index: i,
cue: c,
editor: editor,
orderError: editor.invalidOrder.contains(i),
selected: editor.selected == c,
active: active == c,
onSelect: () => onSelect(c),
);
},
),
),
),
],
);
}
/// 재생 조작 막대: 5초 뒤 · 재생 · 5초 앞 · 위치 · 빠르기 · 줄 단추 (F7 ~ F10, 고른 줄이 없으면 줄 단추는 꺼짐)
class SubtitleTransportBar extends StatelessWidget {
final ValueListenable<Duration> position;
final Duration duration;
final bool playing;
final double rate;
final bool hasSelection;
final void Function(int ms) onSeekBy;
final VoidCallback onPlayPause;
final void Function(double rate) onRate;
final VoidCallback onInsert, onPlaySelected, onSetStart, onSetEnd, onAlign;
const SubtitleTransportBar({
super.key,
required this.position,
required this.duration,
required this.playing,
required this.rate,
required this.hasSelection,
required this.onSeekBy,
required this.onPlayPause,
required this.onRate,
required this.onInsert,
required this.onPlaySelected,
required this.onSetStart,
required this.onSetEnd,
required this.onAlign,
});
@override
Widget build(BuildContext context) {
final hasSel = hasSelection;
Widget btn(String label, String key, VoidCallback? onTap) => Padding(
padding: const EdgeInsets.only(left: 6),
child: Tooltip(
message: key,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10),
visualDensity: VisualDensity.compact),
onPressed: onTap,
child: Text(label, style: const TextStyle(fontSize: 12)),
),
),
);
return Container(
height: 48,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
tooltip: tr('5초 뒤로'),
icon: const Icon(Icons.replay_5),
onPressed: () => onSeekBy(-5000),
),
IconButton(
tooltip: keyHint(tr('재생/정지'), 'Ctrl+Space'),
iconSize: 30,
icon: Icon(playing ? Icons.pause_circle : Icons.play_circle,
color: JjColors.accent),
onPressed: onPlayPause,
),
IconButton(
tooltip: tr('5초 앞으로'),
icon: const Icon(Icons.forward_5),
onPressed: () => onSeekBy(5000),
),
const SizedBox(width: 6),
ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (_, pos, _) => Text(
'${formatSrtTime(pos)} / ${formatSrtTime(duration)}',
style: const TextStyle(fontFamily: 'Consolas', fontSize: 13),
),
),
const SizedBox(width: 10),
DropdownButton<double>(
value: rate,
isDense: true,
underline: const SizedBox(),
style: TextStyle(fontSize: 12, color: JjColors.text),
items: [
for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0])
DropdownMenuItem(value: r, child: Text('${r}x')),
],
onChanged: (r) => onRate(r!),
),
// 폭이 좁으면 (휴대폰) 밀어서 본다. 넓으면 지금처럼 오른쪽에 붙는다 (reverse)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(mainAxisSize: MainAxisSize.min, children: [
btn(tr('+ 여기에 새 줄'), 'F7', onInsert),
btn(tr('선택 줄 재생'), 'F8', hasSel ? onPlaySelected : null),
btn(tr('시작 = 현재'), 'F9', hasSel ? onSetStart : null),
btn(tr('끝 = 현재'), 'F10', hasSel ? onSetEnd : null),
btn(tr('선택 줄부터 여기로 맞추기'), tr('선택한 줄과 그 뒤 모든 줄을 함께 이동'), hasSel ? onAlign : null),
]),
),
),
],
),
);
}
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow();
@override
Widget build(BuildContext context) => Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: LayoutBuilder(builder: (context, box) {
if (!_cueNarrow(box.maxWidth)) {
return Row(children: [
SizedBox(width: 48, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
Expanded(child: Text(tr('내용'), style: _hdr)),
SizedBox(width: 96),
]);
}
// F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
SizedBox(width: _narrowNo, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)),
]);
}),
);
}
/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서
/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움)
bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200;
/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368)
const _narrowNo = 28.0;
const _narrowButton = 40.0;
final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
class _CueRow extends StatefulWidget {
final int index;
final Cue cue;
final SubtitleEditorController editor;
final bool orderError;
final bool selected;
/// 현재 재생 위치에서 표시 중인 줄
final bool active;
final VoidCallback? onSelect;
const _CueRow({
super.key,
required this.index,
required this.cue,
required this.editor,
required this.orderError,
this.selected = false,
this.active = false,
this.onSelect,
});
@override
State<_CueRow> createState() => _CueRowState();
}
class _CueRowState extends State<_CueRow> {
late final _start = TextEditingController(text: formatSrtTime(widget.cue.start));
late final _end = TextEditingController(text: formatSrtTime(widget.cue.end));
late final _text = TextEditingController(text: widget.cue.text);
final _startFocus = FocusNode();
final _endFocus = FocusNode();
bool _startBad = false, _endBad = false;
@override
void didUpdateWidget(covariant _CueRow old) {
super.didUpdateWidget(old);
// 전체 이동 등 바깥에서 바뀐 시간을 입력칸에 반영 (입력 중이 아닐 때만)
if (!_startFocus.hasFocus && !_startBad) _sync(_start, formatSrtTime(widget.cue.start));
if (!_endFocus.hasFocus && !_endBad) _sync(_end, formatSrtTime(widget.cue.end));
}
void _sync(TextEditingController c, String v) {
if (c.text != v) c.text = v;
}
@override
void dispose() {
_start.dispose();
_end.dispose();
_text.dispose();
_startFocus.dispose();
_endFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final e = widget.editor;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: widget.orderError
? JjColors.danger.withValues(alpha: 0.08)
: widget.selected
? JjColors.accent.withValues(alpha: 0.12)
: null,
border: Border(
bottom: BorderSide(color: JjColors.border),
left: BorderSide(
color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
),
),
child: LayoutBuilder(builder: (context, box) {
final narrow = _cueNarrow(box.maxWidth);
final number = SizedBox(
width: narrow ? _narrowNo : 48,
child: InkWell(
onTap: widget.onSelect,
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: Text('${widget.index + 1}',
style: TextStyle(
fontSize: 12,
color: widget.selected ? JjColors.accent : JjColors.textDim)),
),
),
);
final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true));
final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false));
final text = TextField(
controller: _text,
minLines: 1,
maxLines: 4,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(isDense: true, border: InputBorder.none),
onTap: () => e.select(widget.cue),
onChanged: (t) => e.setText(widget.cue, t),
);
// 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게
final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null;
final buttons = [
IconButton(
tooltip: tr('아래에 줄 추가'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index),
),
IconButton(
tooltip: tr('줄 삭제'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index),
),
];
if (!narrow) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
number,
start,
end,
Expanded(child: text),
SizedBox(width: 96, child: Row(children: buttons)),
]);
}
// F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text),
]);
}),
);
}
Widget _timeField(
TextEditingController c, FocusNode f, bool bad, bool isStart) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: TextField(
controller: c,
focusNode: f,
style: const TextStyle(fontSize: 13, fontFamily: 'Consolas'),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
errorText: bad ? tr('형식 오류') : null,
errorStyle: const TextStyle(fontSize: 10),
),
onTap: () => widget.editor.select(widget.cue),
onChanged: (t) {
final ok = widget.editor.setTime(widget.cue, t, isStart: isStart);
setState(() {
if (isStart) {
_startBad = !ok;
} else {
_endBad = !ok;
}
});
},
onEditingComplete: () {
// 입력을 마치면 표준 형식으로 정리
final d = parseSrtTime(c.text);
if (d != null) c.text = formatSrtTime(d);
f.unfocus();
},
),
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../app/ai_local.dart'; import '../app/ai_jobs.dart';
import '../app/download_manager.dart'; import '../app/download_manager.dart';
import '../core/models.dart'; import '../core/models.dart';
import '../services/downloader.dart'; import '../services/downloader.dart';

View File

@ -5,7 +5,9 @@ import 'package:archive/archive_io.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/ai_local.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/ai_catalog.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart';
import 'package:jj_mkvmaker/core/sd_cli.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart';

View File

@ -0,0 +1,61 @@
// ai_jobs.dart 약속 시험: 실제 시험 폴더에 파일을 쓰는 할 일로 runTask 의 진행 · 끝 · 오류 · 취소를 본다.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/services/image_ai.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('jj_ai_jobs_'));
tearDown(() => dir.deleteSync(recursive: true));
test('runTask: 장마다 할 일 · 진행 · "n장 중 m장째" · 끝나면 비움', () async {
final jobs = AiJobs();
final lines = <String?>[];
final done = await jobs.runTask('일', 3, (i, progress) async {
File(p.join(dir.path, '$i.txt')).writeAsStringSync('$i');
progress(0.5);
lines.add(jobs.statusLine);
}, onCancel: () {}, uses: {'sd15'});
expect(done, 3);
expect(dir.listSync().length, 3);
expect(lines.first, contains('3장 중 1장째'));
expect(lines.last, contains('3장 중 3장째'));
expect(jobs.busy, isFalse);
expect(jobs.inUse, isEmpty);
expect(jobs.statusLine, isNull);
});
test('runTask: 작업 중에 또 부르면 오류 · 취소하면 그 뒤 장은 하지 않음', () async {
final jobs = AiJobs();
var cancelled = false;
final run = jobs.runTask('일', 5, (i, _) async {
await expectLater(jobs.runTask('또', 1, (_, _) async {}, onCancel: () {}), throwsA(isA<ImageAiException>()));
if (i == 1) jobs.cancel();
}, onCancel: () => cancelled = true);
expect(await run, 2);
expect(cancelled, isTrue);
expect(jobs.busy, isFalse);
});
test('runTask: 할 일의 오류는 그대로 · 끝나면 비움', () async {
final jobs = AiJobs();
await expectLater(jobs.runTask('일', 2, (_, _) async => throw const FileSystemException('x'), onCancel: () {}),
throwsA(isA<FileSystemException>()));
expect(jobs.busy, isFalse);
});
test('done · clearDoneView: 다음 일을 시작하면 남긴 것의 dispose 를 부른다', () async {
final jobs = AiJobs();
var disposed = 0;
jobs.done('끝', dir: dir.path, view: () async {}, dispose: () => disposed++);
expect((jobs.lastDone, jobs.lastDoneDir, jobs.lastDoneView != null), ('끝', dir.path, true));
await jobs.runTask('일', 1, (_, _) async {}, onCancel: () {});
expect(disposed, 1);
expect(jobs.lastDone, isNull);
expect(AiJobs.durationText(const Duration(seconds: 90)), '2분');
expect(AiJobs.durationText(const Duration(seconds: 20)), '20초');
});
}

View File

@ -0,0 +1,53 @@
// ai_local_engine.dart 약속 시험: 실제 프로세스 (Windows cmd.exe) 와 실제 PNG 로 입력 · 출력 · 오류를 본다.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/services/image_ai.dart';
void main() {
final cmd = Platform.environment['ComSpec'] ?? r'C:\Windows\System32\cmd.exe';
test('runSdCli: 저장했다고 하면 0 이 아닌 코드여도 성공 · 마지막 줄들을 돌려준다', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)');
// "images saved" 줄은 진행 (저장 단계) 으로 읽혀 마지막 줄들에는 들지 않는다
final tail = await runSdCli(cmd, ['/c', 'echo first&& echo 2 images saved&& exit 1']);
expect(tail, contains('first'));
});
test('runSdCli: 저장 없이 0 이 아닌 코드 → 쉬운 말 · 원문은 detail', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)');
await expectLater(
runSdCli(cmd, ['/c', 'echo [E] boom&& exit 3']),
throwsA(isA<ImageAiException>()
.having((e) => e.message, 'message', sdExitMessage(3))
.having((e) => e.detail, 'detail', contains('[E] boom'))));
});
test('runSdCli: 치명 줄이 있으면 0 으로 끝나도 실패 · onStart 로 프로세스를 넘긴다', () async {
if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)');
Process? started;
await expectLater(runSdCli(cmd, ['/c', 'echo new_sd_ctx_t failed'], onStart: (p) => started = p),
throwsA(isA<ImageAiException>().having((e) => e.detail, 'detail', contains('new_sd_ctx_t failed'))));
expect(started, isNotNull);
});
test('sizeMatches · isSdFatalLine · sdCliLibName · sdExitMessage', () {
final png = img.encodePng(img.Image(width: 7, height: 5));
expect(sizeMatches(png, 7, 5), isTrue);
expect(sizeMatches(png, 5, 7), isFalse);
expect(isSdFatalLine('[E] Upscale Failed'), isTrue);
expect(isSdFatalLine('[I] sampling completed'), isFalse);
expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd fphp asimddp'), 'libsdcli.so');
expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd'), 'libsdcli_v80.so');
expect(sdCliLibName(''), 'libsdcli_v80.so');
expect(sdExitMessage(-4), contains('CPU'));
expect(sdExitMessage(7), contains('7'));
});
test('SdDevice.key = 실행 파일 폴더 이름:backend', () {
expect(SdDevice(['', 'ai', 'engine-vulkan', 'sd-cli.exe'].join(Platform.pathSeparator), 'vulkan0', 'GPU').key,
'engine-vulkan:vulkan0');
});
}

View File

@ -1,7 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/core/sd_cli.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;

View File

@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart';
import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/ai_catalog.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart';

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
import 'package:jj_mkvmaker/core/bookmarks.dart'; import 'package:jj_mkvmaker/core/bookmarks.dart';
import 'package:jj_mkvmaker/ui/bookmark_manager_page.dart';
import 'package:jj_mkvmaker/ui/bookmark_ui.dart'; import 'package:jj_mkvmaker/ui/bookmark_ui.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;

View File

@ -0,0 +1,33 @@
// bookmark_manager_page.dart 약속 시험: 실제 BookmarksController (시험 폴더의 파일) 로 입력 · 출력 · 없어진 폴더를 본다.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
import 'package:jj_mkvmaker/core/bookmarks.dart';
import 'package:jj_mkvmaker/ui/bookmark_manager_page.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dirs.dart';
void main() {
deleteTempDirsAtEnd();
testWidgets('처음 폴더 · 주소 누르면 onOpen · 고른 폴더가 없어지면 즐겨찾기 표시줄로', (tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final bm = BookmarksController(p.join(tempDirUntilEnd('jj_bmm_').path, 'bookmarks.json'));
final box = bm.addFolder('보관함', parentId: BookmarkTree.otherId);
bm.addLink('예제', 'https://ex.example', parentId: box.id);
final opened = <String>[];
await tester.pumpWidget(MaterialApp(home: BookmarkManagerPage(bm: bm, onOpen: opened.add, initialFolderId: box.id)));
expect(find.widgetWithText(ListTile, '예제'), findsOneWidget);
await tester.tap(find.widgetWithText(ListTile, '예제'));
await tester.pumpAndSettle();
expect(opened, ['https://ex.example']);
bm.remove(box.id);
await tester.pumpAndSettle();
expect(find.widgetWithText(ListTile, '예제'), findsNothing);
expect(find.widgetWithText(ListTile, 'YouTube'), findsOneWidget); // 즐겨찾기 표시줄의 기본 내용
});
}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/ai_local_engine.dart';
/// 195: Android 의 AI 엔진 (sd-cli) 판 고르기 · 엔진이 죽은 신호별 문구 /// 195: Android 의 AI 엔진 (sd-cli) 판 고르기 · 엔진이 죽은 신호별 문구
void main() { void main() {

View File

@ -0,0 +1,89 @@
// subtitle_editor_panes.dart 약속 시험: 실제 SubtitleEditorController 로 조각의 입력 · 출력 (편집기 · 콜백) · 오류 표시를 본다.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/subtitle_editor_controller.dart';
import 'package:jj_mkvmaker/core/srt.dart';
import 'package:jj_mkvmaker/ui/subtitle_editor_panes.dart';
SubtitleEditorController _editor() => SubtitleEditorController([
Cue(const Duration(seconds: 1), const Duration(seconds: 2), '첫 줄'),
Cue(const Duration(seconds: 3), const Duration(seconds: 4), '둘째 줄'),
], saveCharset: 'UTF-8');
Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child));
void main() {
testWidgets('SubtitleCueTable: 번호를 누르면 onSelect · 시간 고치기 · 틀린 형식 · 줄 추가 · 삭제는 편집기로', (tester) async {
tester.view.physicalSize = const Size(1200, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final e = _editor();
final active = ValueNotifier<Cue?>(null);
final picked = <Cue>[];
await tester.pumpWidget(_host(ListenableBuilder(
listenable: e, builder: (_, _) => SubtitleCueTable(editor: e, active: active, onSelect: picked.add))));
await tester.tap(find.text('2'));
expect(picked, [e.cues[1]]);
await tester.enterText(find.text('00:00:01,000'), '00:00:01,500');
expect(e.cues[0].start, const Duration(milliseconds: 1500));
await tester.enterText(find.text('00:00:03,000'), '말도 안 됨');
await tester.pump();
expect(find.text('형식 오류'), findsOneWidget);
expect(e.cues[1].start, const Duration(seconds: 3));
await tester.tap(find.byTooltip('아래에 줄 추가').first);
await tester.pump();
expect(e.cues.length, 3);
await tester.tap(find.byTooltip('줄 삭제').last);
await tester.pump();
expect(e.cues.length, 2);
});
testWidgets('SubtitleTransportBar: 고른 줄이 없으면 줄 단추 꺼짐 · 누르면 콜백', (tester) async {
// 줄 단추가 모두 보이는 넓은 화면 (좁으면 밀어서 본다)
tester.view.physicalSize = const Size(1800, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final calls = <String>[];
Widget bar(bool sel) => _host(SubtitleTransportBar(
position: ValueNotifier(const Duration(seconds: 5)),
duration: const Duration(minutes: 1),
playing: false,
rate: 1.0,
hasSelection: sel,
onSeekBy: (ms) => calls.add('seek $ms'),
onPlayPause: () => calls.add('play'),
onRate: (r) => calls.add('rate $r'),
onInsert: () => calls.add('insert'),
onPlaySelected: () => calls.add('playSel'),
onSetStart: () => calls.add('start'),
onSetEnd: () => calls.add('end'),
onAlign: () => calls.add('align'),
));
await tester.pumpWidget(bar(false));
expect(find.text('00:00:05,000 / 00:01:00,000'), findsOneWidget);
expect(tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, '시작 = 현재')).onPressed, isNull);
await tester.tap(find.byIcon(Icons.replay_5));
await tester.tap(find.byIcon(Icons.play_circle));
await tester.tap(find.text('+ 여기에 새 줄'));
await tester.pumpWidget(bar(true));
await tester.tap(find.text('끝 = 현재'));
await tester.tap(find.text('1.0x'));
await tester.pumpAndSettle();
await tester.tap(find.text('2.0x').last);
await tester.pumpAndSettle();
expect(calls, ['seek -5000', 'play', 'insert', 'end', 'rate 2.0']);
});
testWidgets('SubtitleVideoPane: 지금 위치의 줄 · 재생 오류를 겹쳐 보인다', (tester) async {
final e = _editor();
final pos = ValueNotifier(const Duration(milliseconds: 1200));
await tester.pumpWidget(_host(SubtitleVideoPane(view: const SizedBox(), editor: e, position: pos, error: '열 수 없음')));
expect(find.text('첫 줄'), findsOneWidget);
expect(find.textContaining('열 수 없음'), findsOneWidget);
pos.value = const Duration(milliseconds: 2500);
await tester.pump();
expect(find.text('첫 줄'), findsNothing);
});
}

View File

@ -4,7 +4,8 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/ai_jobs.dart';
import 'package:jj_mkvmaker/app/ai_local_engine.dart';
import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart';
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/settings.dart';