diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cc0ee13..fa2133f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + @@ -132,7 +133,11 @@ + android:foregroundServiceType="dataSync|specialUse"> + + = Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // 실시간 동기화는 앱이 켜져 있는 내내 지켜봐야 하므로 하루 6시간 제한이 있는 dataSync 대신 specialUse (Android 14+) + startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { startForeground(NOTIFICATION_ID, n) @@ -70,7 +73,7 @@ class KeepAliveService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) { nm.createNotificationChannel( NotificationChannel(CHANNEL_ID, "작업 진행", NotificationManager.IMPORTANCE_LOW).apply { - description = "다운로드 · MKV 만들기 · AI 자막 진행 상황" + description = "다운로드 · MKV 만들기 · AI 자막 · 동기화 진행 상황" setShowBadge(false) } ) diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index 969faa7..11c40b9 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -1,6 +1,7 @@ package com.jj.jj_mkvmaker import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -12,6 +13,7 @@ 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 @@ -20,6 +22,40 @@ class MainActivity : FlutterActivity() { companion object { /// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다 const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS" + + private const val ENGINE_ID = "main" + + /// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기) + /// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠). + @Volatile + var background = false + } + + /// 앞 화면이 닫힌 뒤 살아 있던 엔진을 다시 붙였는지 (Dart 는 이미 돌고 있다) + private var reusedEngine = false + + /// 엔진은 앱 (프로세스) 이 갖는다: 화면이 닫혀도 [background] 면 살려 두고, 다시 열면 그 엔진을 붙인다 + override fun provideFlutterEngine(context: Context): FlutterEngine { + val cache = FlutterEngineCache.getInstance() + cache.get(ENGINE_ID)?.let { + reusedEngine = true + return it + } + return FlutterEngine(context.applicationContext).also { cache.put(ENGINE_ID, it) } + } + + /// 백그라운드로 실행이 꺼져 있으면 지금처럼 화면과 함께 엔진도 끝낸다 + override fun shouldDestroyEngineWithHost(): Boolean { + if (background) return false + FlutterEngineCache.getInstance().remove(ENGINE_ID) + return true + } + + /// 첫 화면에서 ←: 백그라운드로 실행이면 앱을 끝내지 않고 뒤로 보낸다 (홈 버튼과 같음) + override fun popSystemNavigator(): Boolean { + if (!background) return false + moveTaskToBack(true) + return true } private var channel: MethodChannel? = null @@ -36,8 +72,30 @@ class MainActivity : FlutterActivity() { pendingShowJobs = intent?.action == ACTION_SHOW_JOBS val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") channel = ch + if (reusedEngine) { + // Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다 + if (pendingShowJobs) ch.invokeMethod("showJobs", null) + if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen) + pendingShowJobs = false + pendingOpen = emptyList() + } ch.setMethodCallHandler { call, result -> when (call.method) { + "setBackground" -> { + background = call.argument("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() @@ -81,7 +139,7 @@ class MainActivity : FlutterActivity() { result.success(null) } "stopKeepAlive" -> { - stopService(Intent(this, KeepAliveService::class.java)) + stopService(Intent(applicationContext, KeepAliveService::class.java)) result.success(null) } "downloadToolsInit" -> inBackground(result) { downloadToolsInit() } @@ -345,21 +403,22 @@ class MainActivity : FlutterActivity() { private var askedNotifications = false private fun keepAlive(text: String, progress: Int) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && + 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(this, text, progress) + KeepAliveService.update(applicationContext, text, progress) return } - val i = Intent(this, KeepAliveService::class.java) + val i = Intent(applicationContext, KeepAliveService::class.java) .putExtra(KeepAliveService.EXTRA_TEXT, text) .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(i) else startService(i) + 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) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 27c3869..11376e5 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -217,6 +217,7 @@ "… 외 {0}개": "… and {0} more", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ This takes a long time (1 hour of video ≈ 15–30 min speech recognition + 10–20 min translation per language)", + "← 로 닫거나 최근 앱 목록에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속합니다 (알림에 표시). 끄면 지금처럼 닫을 때 끝납니다. 완전히 끝내려면 [종료].": "Closing with ← or swiping away from recent apps keeps sync · MKV creation · downloads running (shown in a notification). When off, closing ends the app as before. Use [Exit] to quit completely.", "− 차갑게 · + 따뜻하게": "− cooler · + warmer", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "Drag ⋮⋮ to reorder · drag the icon into a folder · right-click menu", "── 시작 {0} (Android) ──": "── Start {0} (Android) ──", @@ -250,9 +251,11 @@ "계속 편집": "Keep editing", "고르기": "Choose", "고르지 않음": "Not chosen", + "고른 것 시작": "Start selected", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)", "고지 문서": "Notices", "고화질": "High quality", + "골라서 시작": "Choose and start", "관리 메뉴": "Manage menu", "광고 건너뛰는 중…": "Skipping ad…", "권한 허용 화면 열기": "Open the permission screen", @@ -343,6 +346,7 @@ "다시 만들기 대기로": "Back to waiting for remake", "다시 세기": "Recount", "다시 시작": "Restart", + "다시 켤 때 동기화": "Sync on restart", "다운로드": "Downloads", "다운로드 (jj_yt-dlp · jj_aria2)": "Downloads (jj_yt-dlp · jj_aria2)", "다운로드 ({0})": "Downloads ({0})", @@ -386,6 +390,8 @@ "덴마크어": "Danish", "독일어": "German", "돌아가기": "Go back", + "동기화 {0}개 · 맞추는 중": "{0} syncs · syncing", + "동기화 {0}개 지켜보는 중": "Watching {0} syncs", "동기화 시간": "Sync time", "동기화 시간 (계속 / 시간 지정)": "Sync time (always / scheduled)", "동시 MKV 변환 수": "Simultaneous MKV conversions", @@ -434,14 +440,19 @@ "매번 고르기": "Ask every time", "매일 종일": "Every day, all day", "멈춤": "Stopped", + "멈춤 (이번 실행 동안)": "Pause (this session)", + "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again", "멈춤 · {0}": "Stopped · {0}", "모니터링": "Monitor", "모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}", + "모두 나중에": "All later", "모두 내장 플레이어": "Built-in player for all", + "모두 멈춤": "Pause all", "모두 선택": "Select all", + "모두 시작 (멈춘 것 {0}개)": "Start all ({0} paused)", "모두 종료": "Quit all", "모두 지우기": "Clear all", "모두 초기화": "Reset all", @@ -463,6 +474,8 @@ "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바뀐 날": "Modified", "바뀐 내용": "Changes", + "바로 시작 (기본)": "Start right away (default)", + "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "Start right away: start all enabled syncs · Choose: pick at startup · Don't start: use the start button in Monitor > lsync", "바로 재생 (기본)": "Play right away (default)", "바로 전 작업: {0}": "Previous job: {0}", "받는 중": "Downloading", @@ -484,6 +497,7 @@ "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)", "백그라운드로": "To background", "백그라운드로 계속 (기본)": "Keep running in the background (default)", + "백그라운드로 실행": "Run in background", "버튼 구성": "Button layout", "번역 모델 내려받는 중 {0}%": "Downloading translation model {0}%", "번역 모델 내려받는 중: {0}": "Downloading translation model: {0}", @@ -599,12 +613,15 @@ "시작": "Start", "시작 = 현재": "Start = now", "시작 · 웹 브라우저": "Startup · web browser", + "시작 안 함 (모니터링에서 시작)": "Don't start (start from Monitor)", "시작: {0} → {1}\\{2}{3}": "Started: {0} → {1}\\{2}{3}", + "시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "Choose which syncs to start. The others stay paused for this session (start them in Monitor > lsync).", "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)", "시작할 때마다 설정 창 보기": "Show the setup window at every start", "시즌": "Season", "실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)", "실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}", + "실시간 동기화 시작": "Start live sync", "실시간 동기화 실패: {0} → {1}: {2}": "Live sync failed: {0} → {1}: {2}", "실시간 동기화 추가": "Add live sync", "실시간 동기화: 대상 폴더": "Live sync: target folder", @@ -640,6 +657,7 @@ "앱 안 브라우저 (권장)": "In-app browser (recommended)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)", + "앱을 다시 켤 때 동기화": "Sync when the app restarts", "앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", "언어": "Language", @@ -951,6 +969,7 @@ "창 보이기 / 숨기기 단축키": "Hotkey to show / hide the window", "창 숨김 → 프로세스 종료": "Window hidden → process exit", "창 종료": "Close window", + "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "Closing the window (✕) keeps sync · MKV creation · downloads running from the tray. When off, closing ends everything. (Same setting as \"When closing\" under Run · Exit)", "찾기": "Search", "찾기: {0}": "Search: {0}", "찾는 즐겨찾기가 없습니다.": "No matching bookmarks.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 3be4655..66ea652 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -217,6 +217,7 @@ "… 외 {0}개": "… ほか {0} 件", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)", + "← 로 닫거나 최근 앱 목록에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속합니다 (알림에 표시). 끄면 지금처럼 닫을 때 끝납니다. 완전히 끝내려면 [종료].": "← で閉じたり最近のアプリから払っても同期 · MKV 作成 · ダウンロードを続けます (通知に表示)。オフにすると今まで通り閉じたときに終了します。完全に終了するには [終了]。", "− 차갑게 · + 따뜻하게": "− 寒色 · + 暖色", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "⋮⋮ をドラッグして並べ替え・アイコンをドラッグしてフォルダーへ・右クリックメニュー", "── 시작 {0} (Android) ──": "── 開始 {0}(Android)──", @@ -250,9 +251,11 @@ "계속 편집": "編集を続ける", "고르기": "選ぶ", "고르지 않음": "未選択", + "고른 것 시작": "選んだものを開始", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)", "고지 문서": "告知文書", "고화질": "高画質", + "골라서 시작": "選んで開始", "관리 메뉴": "管理メニュー", "광고 건너뛰는 중…": "広告をスキップ中…", "권한 허용 화면 열기": "権限の許可画面を開く", @@ -343,6 +346,7 @@ "다시 만들기 대기로": "作り直し待ちに戻す", "다시 세기": "再カウント", "다시 시작": "再起動", + "다시 켤 때 동기화": "再起動時の同期", "다운로드": "ダウンロード", "다운로드 (jj_yt-dlp · jj_aria2)": "ダウンロード(jj_yt-dlp・jj_aria2)", "다운로드 ({0})": "ダウンロード({0})", @@ -386,6 +390,8 @@ "덴마크어": "デンマーク語", "독일어": "ドイツ語", "돌아가기": "戻る", + "동기화 {0}개 · 맞추는 중": "同期 {0} 件 · 同期中", + "동기화 {0}개 지켜보는 중": "同期 {0} 件を監視中", "동기화 시간": "同期時間", "동기화 시간 (계속 / 시간 지정)": "同期時間 (常時 / 時間指定)", "동시 MKV 변환 수": "同時 MKV 変換数", @@ -434,14 +440,19 @@ "매번 고르기": "毎回選ぶ", "매일 종일": "毎日 終日", "멈춤": "停止", + "멈춤 (이번 실행 동안)": "停止 (今回の実行中)", + "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します", "멈춤 · {0}": "停止 · {0}", "모니터링": "モニタリング", "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}", + "모두 나중에": "すべて後で", "모두 내장 플레이어": "すべて内蔵プレーヤー", + "모두 멈춤": "すべて停止", "모두 선택": "すべて選択", + "모두 시작 (멈춘 것 {0}개)": "すべて開始 (停止中 {0} 件)", "모두 종료": "すべて終了", "모두 지우기": "すべて削除", "모두 초기화": "すべてリセット", @@ -463,6 +474,8 @@ "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", "바뀐 날": "更新日時", "바뀐 내용": "変更内容", + "바로 시작 (기본)": "すぐ開始 (既定)", + "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "すぐ開始: オンの同期をすべて開始 · 選んで開始: 起動時に選ぶ · 開始しない: モニタリング > lsync の開始ボタンで", "바로 재생 (기본)": "すぐ再生(既定)", "바로 전 작업: {0}": "直前の作業: {0}", "받는 중": "ダウンロード中", @@ -484,6 +497,7 @@ "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", + "백그라운드로 실행": "バックグラウンドで実行", "버튼 구성": "ボタン構成", "번역 모델 내려받는 중 {0}%": "翻訳モデルをダウンロード中 {0}%", "번역 모델 내려받는 중: {0}": "翻訳モデルをダウンロード中: {0}", @@ -599,12 +613,15 @@ "시작": "開始", "시작 = 현재": "開始 = 現在", "시작 · 웹 브라우저": "起動・ウェブブラウザー", + "시작 안 함 (모니터링에서 시작)": "開始しない (モニタリングで開始)", "시작: {0} → {1}\\{2}{3}": "開始: {0} → {1}\\{2}{3}", + "시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "開始する同期を選んでください。選ばなかったものは今回の実行中は停止します (モニタリング > lsync で開始)。", "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시즌": "シーズン", "실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)", "실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}", + "실시간 동기화 시작": "リアルタイム同期の開始", "실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}", "실시간 동기화 추가": "リアルタイム同期を追加", "실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー", @@ -640,6 +657,7 @@ "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", + "앱을 다시 켤 때 동기화": "アプリ再起動時の同期", "앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", "언어": "言語", @@ -951,6 +969,7 @@ "창 보이기 / 숨기기 단축키": "ウィンドウ表示 / 非表示のショートカット", "창 숨김 → 프로세스 종료": "ウィンドウ非表示 → プロセス終了", "창 종료": "ウィンドウを閉じる", + "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "ウィンドウを閉じても (✕) トレイで同期 · MKV 作成 · ダウンロードを続けます。オフにすると閉じたときにすべて終了します。(実行 · 終了の「終了を押したら」と同じ設定)", "찾기": "検索", "찾기: {0}": "検索: {0}", "찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 47ad331..158254a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -217,6 +217,7 @@ "… 외 {0}개": "… 另外 {0} 个", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)", + "← 로 닫거나 최근 앱 목록에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속합니다 (알림에 표시). 끄면 지금처럼 닫을 때 끝납니다. 완전히 끝내려면 [종료].": "用 ← 关闭或从最近应用中划掉后仍继续同步 · 制作 MKV · 下载 (在通知中显示)。关闭此项则像以前一样关闭时结束。要完全退出请按 [退出]。", "− 차갑게 · + 따뜻하게": "− 偏冷 · + 偏暖", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "拖动 ⋮⋮ 调整顺序 · 拖动图标放入文件夹 · 右键菜单", "── 시작 {0} (Android) ──": "── 启动 {0}(Android)──", @@ -250,9 +251,11 @@ "계속 편집": "继续编辑", "고르기": "选择", "고르지 않음": "未选择", + "고른 것 시작": "开始所选", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)", "고지 문서": "声明文件", "고화질": "高画质", + "골라서 시작": "选择后开始", "관리 메뉴": "管理菜单", "광고 건너뛰는 중…": "正在跳过广告…", "권한 허용 화면 열기": "打开权限授予界面", @@ -343,6 +346,7 @@ "다시 만들기 대기로": "改回等待重新制作", "다시 세기": "重新统计", "다시 시작": "重新启动", + "다시 켤 때 동기화": "重新启动时同步", "다운로드": "下载", "다운로드 (jj_yt-dlp · jj_aria2)": "下载(jj_yt-dlp · jj_aria2)", "다운로드 ({0})": "下载({0})", @@ -386,6 +390,8 @@ "덴마크어": "丹麦语", "독일어": "德语", "돌아가기": "返回", + "동기화 {0}개 · 맞추는 중": "同步 {0} 个 · 同步中", + "동기화 {0}개 지켜보는 중": "正在监视 {0} 个同步", "동기화 시간": "同步时间", "동기화 시간 (계속 / 시간 지정)": "同步时间 (持续 / 指定时间)", "동시 MKV 변환 수": "同时转换 MKV 数", @@ -434,14 +440,19 @@ "매번 고르기": "每次选择", "매일 종일": "每天 全天", "멈춤": "已停止", + "멈춤 (이번 실행 동안)": "暂停 (本次运行期间)", + "멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步", "멈춤 · {0}": "已停止 · {0}", "모니터링": "监控", "모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}", + "모두 나중에": "全部稍后", "모두 내장 플레이어": "全部使用内置播放器", + "모두 멈춤": "全部暂停", "모두 선택": "全选", + "모두 시작 (멈춘 것 {0}개)": "全部开始 (已暂停 {0} 个)", "모두 종료": "全部退出", "모두 지우기": "全部清除", "모두 초기화": "全部重置", @@ -463,6 +474,8 @@ "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", "바뀐 날": "修改时间", "바뀐 내용": "更改内容", + "바로 시작 (기본)": "立即开始 (默认)", + "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "立即开始: 开始所有已启用的同步 · 选择后开始: 启动时选择 · 不开始: 用 监控 > lsync 的开始按钮", "바로 재생 (기본)": "立即播放(默认)", "바로 전 작업: {0}": "上一个任务: {0}", "받는 중": "正在下载", @@ -484,6 +497,7 @@ "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", "백그라운드로 계속 (기본)": "在后台继续(默认)", + "백그라운드로 실행": "在后台运行", "버튼 구성": "按钮配置", "번역 모델 내려받는 중 {0}%": "正在下载翻译模型 {0}%", "번역 모델 내려받는 중: {0}": "正在下载翻译模型: {0}", @@ -599,12 +613,15 @@ "시작": "开始", "시작 = 현재": "开始 = 当前", "시작 · 웹 브라우저": "启动 · 网页浏览器", + "시작 안 함 (모니터링에서 시작)": "不开始 (在监控中开始)", "시작: {0} → {1}\\{2}{3}": "开始: {0} → {1}\\{2}{3}", + "시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "请选择要开始的同步。未选的在本次运行期间暂停 (可在 监控 > lsync 中开始)。", "시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)", "시작할 때마다 설정 창 보기": "每次启动时显示设置窗口", "시즌": "季", "실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)", "실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}", + "실시간 동기화 시작": "开始实时同步", "실시간 동기화 실패: {0} → {1}: {2}": "实时同步失败: {0} → {1}: {2}", "실시간 동기화 추가": "添加实时同步", "실시간 동기화: 대상 폴더": "实时同步: 目标文件夹", @@ -640,6 +657,7 @@ "앱 안 브라우저 (권장)": "应用内浏览器(推荐)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", + "앱을 다시 켤 때 동기화": "应用重新启动时同步", "앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", "언어": "语言", @@ -951,6 +969,7 @@ "창 보이기 / 숨기기 단축키": "显示 / 隐藏窗口的快捷键", "창 숨김 → 프로세스 종료": "窗口隐藏 → 进程退出", "창 종료": "关闭窗口", + "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "关闭窗口 (✕) 后在托盘中继续同步 · 制作 MKV · 下载。关闭此项则关闭时全部结束。(与 运行 · 退出 中的“按退出时”为同一设置)", "찾기": "搜索", "찾기: {0}": "搜索: {0}", "찾는 즐겨찾기가 없습니다.": "没有匹配的书签。", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 92902fb..d1fab9b 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -57,8 +57,13 @@ class LiveSync extends ChangeNotifier { /// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다 Timer? _clock; - void start() { + /// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다) + final paused = {}; + + /// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작) + void start({bool hold = false}) { instance = this; + if (hold) paused.addAll(c.settings.liveSyncPairs.where((x) => x.enabled).map(keyOf)); c.addListener(_apply); _apply(); _clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock()); @@ -76,8 +81,35 @@ class LiveSync extends ChangeNotifier { /// 지금 동작 시간인지 (일정이 없으면 늘) static bool activeNow(LiveSyncPair x, [DateTime? now]) => scheduleActive(x.schedule, now ?? DateTime.now()); + bool isPaused(LiveSyncPair x) => paused.contains(keyOf(x)); + + /// 지금 지켜보는 쌍 (켜져 있고 멈추지 않은 것) + List get watching => [ + for (final x in c.settings.liveSyncPairs) + if (x.enabled && !paused.contains(keyOf(x))) x, + ]; + + void pause(LiveSyncPair x) => _setPaused({...paused, keyOf(x)}); + void resume(LiveSyncPair x) => _setPaused({...paused}..remove(keyOf(x))); + void pauseAll() => _setPaused({...paused, ...c.settings.liveSyncPairs.map(keyOf)}); + void resumeAll() => _setPaused({}); + + /// 시작할 때 고른 쌍만 (나머지는 멈춤) + void runOnly(Iterable xs) { + final keep = xs.map(keyOf).toSet(); + _setPaused({for (final x in c.settings.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)}); + } + + void _setPaused(Set v) { + paused + ..clear() + ..addAll(v); + _apply(); + notifyListeners(); + } + void _onClock() { - for (final x in c.settings.liveSyncPairs.where((x) => x.enabled)) { + for (final x in watching) { if (activeNow(x) && (pending[keyOf(x)]?.isNotEmpty ?? false)) _schedule(x); } notifyListeners(); // 남은 시간 표시 @@ -144,7 +176,7 @@ class LiveSync extends ChangeNotifier { /// 설정이 바뀌면 감시를 다시 꾸린다 void _apply() { final s = c.settings; - final pairs = s.liveSyncPairs.where((x) => x.enabled).toList(); + final pairs = watching; final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]); if (sig == _sig) return; _sig = sig; @@ -187,6 +219,9 @@ class LiveSync extends ChangeNotifier { bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x)); + /// 지금 맞추는 쌍이 있는지 + bool get anyRunning => _running.isNotEmpty; + /// 지금 맞추기 (원본 내용 → 대상) Future syncNow(LiveSyncPair x) async { final k = keyOf(x); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 85f0b3c..25dca31 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -322,6 +322,23 @@ class AppSettings { /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. int liveSyncIntervalSec = 30; + /// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시). + /// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]). + bool runInBackground = false; + + /// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작) + String liveSyncOnStart = 'auto'; + + /// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground] + bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground; + set backgroundRun(bool v) { + if (Platform.isWindows) { + closeAction = v ? 'background' : 'quit'; + } else { + runInBackground = v; + } + } + /// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창) List explorerPaths = []; @@ -414,6 +431,8 @@ class AppSettings { 'rsyncPath': rsyncPath, 'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()], 'liveSyncIntervalSec': liveSyncIntervalSec, + 'runInBackground': runInBackground, + 'liveSyncOnStart': liveSyncOnStart, 'copyMonitor': copyMonitor, 'copyTasks': [for (final x in copyTasks) x.toJson()], 'explorerHistory': explorerHistory, @@ -519,6 +538,8 @@ class AppSettings { ] ..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600) ..copyMonitor = j['copyMonitor'] == true + ..runInBackground = j['runInBackground'] == true + ..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto' ..copyTasks = [ for (final x in (j['copyTasks'] as List?) ?? const []) if (x is Map && x['dest'] is String) CopyTask.fromJson(x), diff --git a/lib/main.dart b/lib/main.dart index ce6850b..dfec908 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,7 @@ import 'ui/downloads_page.dart'; import 'ui/explorer_page.dart'; import 'ui/exit_dialog.dart'; import 'ui/home_page.dart'; +import 'ui/monitor_page.dart' show askLiveSyncStart; import 'ui/player_page.dart'; import 'ui/setup_dialog.dart'; import 'ui/update_dialog.dart'; @@ -91,8 +92,8 @@ Future main(List args) async { controller.note(trf('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). ' '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.', [hm(crashed.$1), hm(crashed.$2)])); } await controller.init(); - // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안 - LiveSync(controller).start(); + // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 + final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); await i18n.apply(controller.settings.uiLanguage, save: false); @@ -295,6 +296,8 @@ Future main(List args) async { final nav = navigatorKey.currentState; if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); } + if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live); + if (!ctx.mounted) return; await checkRequiredTools(ctx, services.shell); final ctx2 = navigatorKey.currentContext; if (ctx2 != null && ctx2.mounted) await checkForUpdate(ctx2, controller); @@ -374,8 +377,8 @@ Future runAndroid(String dataDir) async { final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log'); controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); await controller.init(); - // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안 - LiveSync(controller).start(); + // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 + final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); await i18n.apply(controller.settings.uiLanguage, save: false); @@ -414,6 +417,8 @@ Future runAndroid(String dataDir) async { downloads.log = controller.note; // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) AndroidKeepAlive(controller, downloads); + // 백그라운드로 실행: 화면을 닫았다가 다시 열면 새 화면 (Activity) 이 살아 있던 엔진에 붙는다 → 화면 방향을 다시 적용 + AppLifecycleListener(onResume: () => applyScreenOrientation(controller.settings.screenOrientation)); // 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일 unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); @@ -452,6 +457,9 @@ Future runAndroid(String dataDir) async { final nav = navigatorKey.currentState; if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); } + // 다시 켤 때 실시간 동기화를 골라서 시작 + final ctx0 = navigatorKey.currentContext; + if (controller.settings.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live); // 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음) final ctx = navigatorKey.currentContext; if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller)); diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index 7b04ab5..c4d2826 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -4,11 +4,14 @@ import 'package:flutter/services.dart'; import '../../app/app_controller.dart'; import '../../app/download_manager.dart'; +import '../../app/live_sync.dart'; import '../../services/downloader.dart'; import '../../l10n/tr.dart'; /// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt) /// 를 띄워 앱이 화면에서 내려가도 계속하게 하고, 알림에 진행 상황을 보여 준다. 모두 끝나면 내린다. +/// [AppSettings.runInBackground] 를 켜면 실시간 동기화를 지켜보는 동안에도 띄워 두고, 앱을 닫아도 (← · 최근 앱에서 밀기) +/// Dart 엔진을 살려 둔다 (MainActivity.setBackground). class AndroidKeepAlive { static const _ch = MethodChannel('jj_mkvmaker/android'); @@ -21,18 +24,37 @@ class AndroidKeepAlive { AndroidKeepAlive(this.c, this.downloads) { c.addListener(_changed); downloads.addListener(_changed); + _changed(); } + LiveSync? _live; + bool? _background; + /// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다 void _changed() => _timer ??= Timer(const Duration(seconds: 1), _apply); Future _apply() async { _timer = null; + // 실시간 동기화 (나중에 만들어질 수도 있다) + final live = LiveSync.instance; + if (live != _live) { + _live?.removeListener(_changed); + _live = live?..addListener(_changed); + } + final bg = c.settings.runInBackground; + if (bg != _background) { + _background = bg; + try { + await _ch.invokeMethod('setBackground', {'on': bg}); + } catch (_) {} + } // 일시정지한 다운로드는 세지 않는다 (받는 중 · 대기 중만) final running = downloads.tasks .where((t) => t.state == DownloadState.downloading || t.state == DownloadState.queued) .length; - final (text, progress) = status(c.currentJob, c.busy, running, downloads.overallProgress); + final syncs = bg && live != null ? live.watching.length : 0; + final (text, progress) = + status(c.currentJob, c.busy, running, downloads.overallProgress, syncs: syncs, syncing: live?.anyRunning ?? false); try { if (text == null) { if (_on) await _ch.invokeMethod('stopKeepAlive'); @@ -49,10 +71,13 @@ class AndroidKeepAlive { } /// 알림 글과 진행률 (0~100, 모르면 -1). 진행 중인 일이 없으면 글이 null. - static (String?, int) status(String? job, bool busy, int downloads, double? downloadProgress) { + /// [syncs] 백그라운드로 지켜보는 실시간 동기화 수 ([syncing] 지금 맞추는 중) + static (String?, int) status(String? job, bool busy, int downloads, double? downloadProgress, + {int syncs = 0, bool syncing = false}) { final parts = [ if (busy) job ?? tr('작업 중'), if (downloads > 0) trf('다운로드 {0}개{1}', [downloads, downloadProgress == null ? '' : ' · ${(downloadProgress * 100).round()}%']), + if (syncs > 0) syncing ? trf('동기화 {0}개 · 맞추는 중', [syncs]) : trf('동기화 {0}개 지켜보는 중', [syncs]), ]; if (parts.isEmpty) return (null, -1); // 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다) diff --git a/lib/platform/android/android_shell.dart b/lib/platform/android/android_shell.dart index 114f34a..3983266 100644 --- a/lib/platform/android/android_shell.dart +++ b/lib/platform/android/android_shell.dart @@ -17,8 +17,15 @@ class AndroidShell extends NoopShell { static const _ch = MethodChannel('jj_mkvmaker/android'); bool _full = false; + /// [종료]: 백그라운드로 살려 둔 엔진 · 작업 알림까지 끝낸다 (화면만 닫는 ← 와 다름) @override - Future quit() => SystemNavigator.pop(); + Future quit() async { + try { + await _ch.invokeMethod('exitApp'); + } catch (_) { + await SystemNavigator.pop(); + } + } @override Future setFullScreen(bool on) async { diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 2bbe88c..11cfcfd 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -286,6 +286,7 @@ class _LiveSyncTile extends StatelessWidget { label: Text(tr('추가')), ), ), + Padding(padding: const EdgeInsets.only(left: 16), child: LiveSyncRunOptions(c: c)), for (var i = 0; i < pairs.length; i++) Padding( padding: const EdgeInsets.only(left: 16), @@ -336,3 +337,66 @@ class _LiveSyncTile extends StatelessWidget { return live == null ? body() : ListenableBuilder(listenable: live, builder: (_, _) => body()); } } + +/// 백그라운드로 실행 · 앱을 다시 켤 때 동기화 (환경 설정 > 파일 탐색기 > 실시간 동기화, 모니터링 > lsync 위) +class LiveSyncRunOptions extends StatelessWidget { + final AppController c; + + /// 모니터링: 한 줄로 짧게 + final bool dense; + const LiveSyncRunOptions({super.key, required this.c, this.dense = false}); + + static String backgroundHelp() => Platform.isWindows + ? tr('창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 "종료를 누르면" 과 같은 설정)') + : tr('← 로 닫거나 최근 앱 목록에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속합니다 (알림에 표시). 끄면 지금처럼 닫을 때 끝납니다. 완전히 끝내려면 [종료].'); + + @override + Widget build(BuildContext context) { + final s = c.settings; + final onStart = DropdownButton( + value: s.liveSyncOnStart, + isDense: dense, + items: [ + DropdownMenuItem(value: 'auto', child: Text(tr('바로 시작 (기본)'))), + DropdownMenuItem(value: 'ask', child: Text(tr('골라서 시작'))), + DropdownMenuItem(value: 'off', child: Text(tr('시작 안 함 (모니터링에서 시작)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.liveSyncOnStart = v!), + ); + void setBackground(bool? v) => c.updateSettings((x) => x.backgroundRun = v ?? false); + if (dense) { + return Wrap(spacing: 12, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Tooltip( + message: backgroundHelp(), + child: InkWell( + onTap: () => setBackground(!s.backgroundRun), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Checkbox(value: s.backgroundRun, onChanged: setBackground), + Text(tr('백그라운드로 실행'), style: const TextStyle(fontSize: 13)), + ]), + ), + ), + Row(mainAxisSize: MainAxisSize.min, children: [ + Text(tr('다시 켤 때 동기화'), style: const TextStyle(fontSize: 13, color: JjColors.textDim)), + const SizedBox(width: 8), + onStart, + ]), + ]); + } + return Column(children: [ + CheckboxListTile( + dense: true, + value: s.backgroundRun, + onChanged: setBackground, + title: Text(tr('백그라운드로 실행')), + subtitle: Text(backgroundHelp()), + ), + ListTile( + dense: true, + title: Text(tr('앱을 다시 켤 때 동기화')), + subtitle: Text(tr('바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로')), + trailing: onStart, + ), + ]); + } +} diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 0140fcc..03d2467 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -13,7 +13,7 @@ import '../core/file_ops.dart'; import '../core/sync_tools.dart'; import '../l10n/tr.dart'; import 'app_actions.dart'; -import 'copy_sync_settings.dart' show addLiveSyncPairDialog; +import 'copy_sync_settings.dart' show addLiveSyncPairDialog, LiveSyncRunOptions; import 'rsync_setup.dart'; import 'schedule_editor.dart'; import 'theme.dart'; @@ -442,6 +442,18 @@ class _LiveTabState extends State<_LiveTab> { ), TextButton.icon(onPressed: () => addLiveSyncPairDialog(context, c), icon: const Icon(Icons.add), label: Text(tr('추가'))), ]), + // 백그라운드로 실행 · 다시 켤 때 동기화 (환경 설정과 같은 값) · 모두 시작 / 멈춤 + Wrap(spacing: 12, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + LiveSyncRunOptions(c: c, dense: true), + if (live != null && pairs.any((x) => x.enabled)) + live.paused.isEmpty + ? TextButton.icon( + onPressed: live.pauseAll, icon: const Icon(Icons.pause, size: 18), label: Text(tr('모두 멈춤'))) + : FilledButton.tonalIcon( + onPressed: live.resumeAll, + icon: const Icon(Icons.play_arrow, size: 18), + label: Text(trf('모두 시작 (멈춘 것 {0}개)', [pairs.where(live.isPaused).length]))), + ]), const SizedBox(height: 8), if (pairs.isEmpty) Padding( @@ -461,6 +473,7 @@ class _LiveTabState extends State<_LiveTab> { final st = live?.status[k]; final active = LiveSync.activeNow(x); final running = live?.isRunning(x) ?? false; + final held = live?.isPaused(x) ?? false; return Card( color: JjColors.panel, margin: const EdgeInsets.only(bottom: 10), @@ -468,8 +481,17 @@ class _LiveTabState extends State<_LiveTab> { padding: const EdgeInsets.fromLTRB(14, 10, 10, 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - Icon(running ? Icons.sync : x.enabled ? (active ? Icons.sync : Icons.bedtime_outlined) : Icons.sync_disabled, - color: x.enabled ? (active ? JjColors.accent : Colors.orangeAccent) : JjColors.textDim), + Icon( + running + ? Icons.sync + : !x.enabled + ? Icons.sync_disabled + : held + ? Icons.pause_circle_outline + : active + ? Icons.sync + : Icons.bedtime_outlined, + color: x.enabled && !held ? (active ? JjColors.accent : Colors.orangeAccent) : JjColors.textDim), const SizedBox(width: 8), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -481,8 +503,21 @@ class _LiveTabState extends State<_LiveTab> { visualDensity: VisualDensity.compact, label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')), ), + // 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치) + if (live != null && x.enabled) + IconButton( + tooltip: held ? tr('시작') : tr('멈춤 (이번 실행 동안)'), + icon: Icon(held ? Icons.play_arrow : Icons.pause, color: held ? JjColors.accent : null), + onPressed: () => held ? live.resume(x) : live.pause(x), + ), Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))), ]), + if (held) + Padding( + padding: const EdgeInsets.only(left: 32, top: 2), + child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + ), Padding( padding: const EdgeInsets.only(left: 32, top: 4), child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -578,3 +613,45 @@ class _LiveTabState extends State<_LiveTab> { ); } } + +/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. +/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작). +Future askLiveSyncStart(BuildContext context, LiveSync live) async { + final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList(); + if (pairs.isEmpty) return; + final pick = {for (final x in pairs) LiveSync.keyOf(x)}; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('실시간 동기화 시작')), + content: SizedBox( + width: 520, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + Flexible( + child: ListView(shrinkWrap: true, children: [ + for (final x in pairs) + CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + value: pick.contains(LiveSync.keyOf(x)), + onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))), + title: Text('${x.source} → ${x.target}', maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text(scheduleSummary(x.schedule)), + ), + ]), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))), + ], + ), + ), + ); + if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x)))); +} diff --git a/test/keep_alive_test.dart b/test/keep_alive_test.dart index c051961..9f70153 100644 --- a/test/keep_alive_test.dart +++ b/test/keep_alive_test.dart @@ -11,5 +11,8 @@ void main() { // 작업 (MKV · AI) 이 있으면 글만 expect(AndroidKeepAlive.status('MKV 만들기', true, 0, null), ('MKV 만들기', -1)); expect(AndroidKeepAlive.status('AI 자막', true, 1, 0.5), ('AI 자막 · 다운로드 1개 · 50%', -1)); + // 백그라운드로 실행: 실시간 동기화를 지켜보는 동안에도 + expect(AndroidKeepAlive.status(null, false, 0, null, syncs: 2), ('동기화 2개 지켜보는 중', -1)); + expect(AndroidKeepAlive.status('MKV 만들기', true, 0, null, syncs: 1, syncing: true), ('MKV 만들기 · 동기화 1개 · 맞추는 중', -1)); }); } diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 0cab66c..4231cde 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -122,6 +122,56 @@ void main() { }); }); + group('백그라운드 · 다시 켤 때 동기화', () { + test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () { + final s = AppSettings() + ..runInBackground = true + ..liveSyncOnStart = 'ask'; + final r = AppSettings.fromJson(s.toJson()); + expect([r.runInBackground, r.liveSyncOnStart], [true, 'ask']); + expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).liveSyncOnStart, 'auto'); + expect(AppSettings.fromJson({}).runInBackground, isFalse); + final w = AppSettings()..backgroundRun = false; + if (Platform.isWindows) { + expect(w.closeAction, 'quit'); + w.backgroundRun = true; + expect([w.closeAction, w.backgroundRun], ['background', true]); + } else { + expect(w.runInBackground, isFalse); + } + }); + + test('멈춘 채로 시작 · 고른 것만 시작 · 모두 시작', () async { + final tmp = Directory.systemTemp.createTempSync('jj_hold_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final a = Directory(p.join(tmp.path, 'a'))..createSync(); + final b = Directory(p.join(tmp.path, 'b'))..createSync(); + File(p.join(a.path, '1.txt')).writeAsStringSync('1'); + File(p.join(b.path, '2.txt')).writeAsStringSync('2'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final pa = LiveSyncPair(a.path, p.join(tmp.path, 'ta')), pb = LiveSyncPair(b.path, p.join(tmp.path, 'tb')); + c.settings + ..liveSyncPairs = [pa, pb] + ..liveSyncOnStart = 'off'; + final live = LiveSync(c)..start(hold: c.settings.liveSyncOnStart != 'auto'); + addTearDown(live.dispose); + expect(live.watching, isEmpty); + await Future.delayed(const Duration(seconds: 4)); + expect(Directory(pa.target).existsSync(), isFalse); // 시작 안 함 + live.runOnly([pa]); + expect(live.watching.map((x) => x.source), [a.path]); + expect(live.isPaused(pb), isTrue); + await Future.delayed(const Duration(seconds: 4)); + expect(File(p.join(pa.target, '1.txt')).existsSync(), isTrue); + expect(Directory(pb.target).existsSync(), isFalse); + live.resumeAll(); + await Future.delayed(const Duration(seconds: 4)); + expect(File(p.join(pb.target, '2.txt')).existsSync(), isTrue); + live.pause(pa); + expect(live.watching.map((x) => x.source), [b.path]); + }); + }); + testWidgets('모니터링 화면: 복사 목록 · 옵션 고치기 → 현재 유지 / 반영 후 실행, lsync 탭 · 일정', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_monui_'); addTearDown(() => tmp.deleteSync(recursive: true));