Merge branch 'split_ui' into modular
commit
0943427cd0
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,32 +1,21 @@
|
|||
package com.jj.jj_mkvmaker
|
||||
|
||||
import android.Manifest
|
||||
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 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.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
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() {
|
||||
companion object {
|
||||
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
|
||||
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 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠).
|
||||
|
|
@ -63,233 +52,31 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
private var channel: MethodChannel? = null
|
||||
|
||||
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다)
|
||||
private var pendingShowJobs = false
|
||||
|
||||
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
|
||||
private var pendingOpen: List<Map<String, String?>> = emptyList()
|
||||
/// 앱이 켜질 때 받은 것 (작업 알림으로 열렸는지 · 다른 앱에서 연 동영상: Dart 가 준비되면 가져간다)
|
||||
private val inbox = LaunchInbox()
|
||||
private val opened by lazy { OpenedFiles(this) }
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
pendingOpen = openedFiles(intent)
|
||||
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
|
||||
inbox.openFiles = opened.of(intent)
|
||||
inbox.showJobs = intent?.action == ACTION_SHOW_JOBS
|
||||
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
|
||||
channel = ch
|
||||
// 88: SMB (smbj) - 탐색기의 SMB 서버 탭
|
||||
SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger)
|
||||
if (reusedEngine) {
|
||||
// Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다
|
||||
if (pendingShowJobs) ch.invokeMethod("showJobs", null)
|
||||
if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen)
|
||||
pendingShowJobs = false
|
||||
pendingOpen = emptyList()
|
||||
if (inbox.showJobs) ch.invokeMethod("showJobs", null)
|
||||
if (inbox.openFiles.isNotEmpty()) ch.invokeMethod("openFiles", inbox.openFiles)
|
||||
inbox.showJobs = false
|
||||
inbox.openFiles = emptyList()
|
||||
}
|
||||
val modules = listOf(AppChannel(this, inbox), StorageChannel(this), SystemChannel(this))
|
||||
ch.setMethodCallHandler { call, result ->
|
||||
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" -> {
|
||||
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()
|
||||
}
|
||||
if (modules.none { it.handle(call, result) }) 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)
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
|
|
@ -297,462 +84,7 @@ class MainActivity : FlutterActivity() {
|
|||
channel?.invokeMethod("showJobs", null)
|
||||
return
|
||||
}
|
||||
val files = openedFiles(intent)
|
||||
val files = opened.of(intent)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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:convert';
|
||||
import 'dart:io';
|
||||
|
|
@ -6,13 +14,14 @@ import 'package:archive/archive_io.dart';
|
|||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
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_jobs.dart';
|
||||
import 'ai_local_engine.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'component_store.dart' show AccumulatorSink;
|
||||
import 'copy_center.dart' show diskSpace;
|
||||
|
|
@ -407,173 +416,6 @@ int? contentRangeStart(String? header) {
|
|||
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)
|
||||
Future<List<SdDevice>> sdDevices(AiStore store) async {
|
||||
final out = <SdDevice>[];
|
||||
|
|
@ -601,184 +443,6 @@ Future<List<SdDevice>> sdDevices(AiStore store) async {
|
|||
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단계) 가장 빠른 것을 기억한다 (장치 목록이 바뀌면 다시 잰다).
|
||||
Future<SdDevice?> pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async {
|
||||
final devices = await sdDevices(store);
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@ import '../core/sd_cli.dart';
|
|||
import '../l10n/tr.dart';
|
||||
import '../services/image_ai.dart';
|
||||
import 'ai_local.dart';
|
||||
import 'ai_local_engine.dart';
|
||||
|
||||
/// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale).
|
||||
/// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/i18n_controller.dart';
|
||||
import '../../app/ai_jobs.dart';
|
||||
import '../../app/ai_local.dart';
|
||||
import '../../app/download_manager.dart';
|
||||
import '../../app/copy_center.dart';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app/ai_jobs.dart';
|
||||
import '../app/ai_local.dart';
|
||||
import '../app/ai_local_engine.dart';
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/reader_sources.dart';
|
||||
import '../core/sd_cli.dart';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/ai_jobs.dart';
|
||||
import '../app/ai_local.dart';
|
||||
import '../app/ai_local_engine.dart';
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/copy_center.dart' show diskSpace;
|
||||
import '../core/ai_catalog.dart';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import 'package:flutter/services.dart' show MethodChannel;
|
|||
import 'package:image/image.dart' as img;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app/ai_jobs.dart';
|
||||
import '../app/ai_local.dart';
|
||||
import '../app/ai_local_engine.dart';
|
||||
import '../app/ai_upscale.dart';
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/ai_catalog.dart';
|
||||
|
|
|
|||
|
|
@ -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!),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,19 @@
|
|||
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 'app_actions.dart';
|
||||
import 'theme.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'notice.dart';
|
||||
|
||||
// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고):
|
||||
// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고). 관리자 화면은 bookmark_manager_page.dart (이 파일에서 나눔).
|
||||
// - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더)
|
||||
// - 오른쪽 클릭 메뉴: 열기 · 수정 · 삭제 · 이 폴더에 페이지 / 폴더 추가 · 관리자
|
||||
// - 끌어다 놓기: 즐겨찾기 · 폴더를 폴더 위에 놓으면 그 폴더 안으로
|
||||
// - 즐겨찾기 관리자 (전체 화면): 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 · HTML 내보내기 / 가져오기
|
||||
// 약속:
|
||||
// - 입력: [BuildContext] · 즐겨찾기 [BookmarksController] · 대상 [BookmarkNode] / 폴더 id · 열기 콜백.
|
||||
// - 출력: 바꾸기는 모두 [BookmarksController] 로 (삭제는 "실행 취소" 알림과 함께), 묻기 창은 고른 값 (취소하면 null).
|
||||
// - 오류: 던지지 않는다. 창이 닫힌 뒤 context 가 없어졌으면 아무것도 하지 않는다.
|
||||
|
||||
/// 끌고 다니는 즐겨찾기 (id)
|
||||
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!),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import '../platform/windows/com_guard.dart';
|
|||
import '../platform/windows/elevation.dart';
|
||||
import '../services/downloader.dart' show DownloadState;
|
||||
import 'app_actions.dart';
|
||||
import 'bookmark_manager_page.dart';
|
||||
import 'bookmark_ui.dart';
|
||||
import 'cef_setup.dart' show confirmCefWhenElevated;
|
||||
import 'confirm.dart';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다.
|
||||
// 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다.
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -11,8 +13,8 @@ import '../core/srt.dart';
|
|||
import '../core/text_codec.dart';
|
||||
import '../services/preview_player.dart';
|
||||
import 'app_actions.dart';
|
||||
import 'key_hint.dart';
|
||||
import 'path_label.dart' show middleEllipsis;
|
||||
import 'subtitle_editor_panes.dart';
|
||||
import 'theme.dart';
|
||||
import 'timeline.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
|
@ -317,172 +319,31 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
|
|||
);
|
||||
}
|
||||
|
||||
// ───────── 영상 · 자막 겹쳐 보기 ─────────
|
||||
// ───────── 화면 조각 (subtitle_editor_panes.dart) ─────────
|
||||
|
||||
Widget _videoPane() => Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_pl!.buildView(),
|
||||
// 편집 중인 자막을 영상 위에 표시
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 20,
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<Duration>(
|
||||
valueListenable: _position,
|
||||
builder: (_, pos, _) => ListenableBuilder(
|
||||
listenable: e,
|
||||
builder: (_, _) {
|
||||
final text = e.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 (_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 _videoPane() => SubtitleVideoPane(view: _pl!.buildView(), editor: e, position: _position, error: _playerError);
|
||||
|
||||
Widget _cueTable() => SubtitleCueTable(editor: e, active: _active, onSelect: _selectCue);
|
||||
|
||||
Widget _transportBar() => SubtitleTransportBar(
|
||||
position: _position,
|
||||
duration: _duration,
|
||||
playing: _playing,
|
||||
rate: _rate,
|
||||
hasSelection: e.selected != null,
|
||||
onSeekBy: _seekBy,
|
||||
onPlayPause: () => _pl?.playOrPause(),
|
||||
onRate: (r) {
|
||||
setState(() => _rate = r);
|
||||
_pl?.setRate(r);
|
||||
},
|
||||
onInsert: _insertHere,
|
||||
onPlaySelected: _playSelected,
|
||||
onSetStart: _setStartHere,
|
||||
onSetEnd: _setEndHere,
|
||||
onAlign: _alignHere,
|
||||
);
|
||||
|
||||
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(
|
||||
nav: const AppNavButtons(),
|
||||
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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/ai_local.dart';
|
||||
import '../app/ai_jobs.dart';
|
||||
import '../app/download_manager.dart';
|
||||
import '../core/models.dart';
|
||||
import '../services/downloader.dart';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'package:archive/archive_io.dart';
|
|||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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_engine.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/ai_catalog.dart';
|
||||
import 'package:jj_mkvmaker/core/sd_cli.dart';
|
||||
|
|
|
|||
|
|
@ -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초');
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
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:path/path.dart' as p;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/ai_catalog.dart';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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:jj_mkvmaker/ui/bookmark_ui.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
|
|||
|
|
@ -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); // 즐겨찾기 표시줄의 기본 내용
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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) 판 고르기 · 엔진이 죽은 신호별 문구
|
||||
void main() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
|
|
|
|||
Loading…
Reference in New Issue