백그라운드로 실행 · 다시 켤 때 동기화 (바로 / 골라서 / 시작 안 함)

- 설정 > 파일 탐색기 > 실시간 동기화 (모니터링 > lsync 위에도): [백그라운드로 실행], [앱을 다시 켤 때 동기화]
- Windows: 백그라운드로 실행 = 창 ✕ 를 누르면 트레이로 (종료 동작 설정과 같은 값), 끄면 닫을 때 종료
- Android: 켜면 ← · 최근 앱에서 밀어도 Dart 엔진을 살려 두고 (엔진 캐시) 동기화 · MKV 만들기 · 다운로드 계속,
  작업 알림 서비스가 동기화를 지켜보는 동안에도 유지 (Android 14+ specialUse), [종료] 는 프로세스까지 끝냄
- lsync: 이번 실행 동안 멈춤 / 시작, 모두 멈춤 / 모두 시작, 켤 때 고르기 창

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 11:53:20 +09:00
parent b38253fd67
commit fa0b7e56b2
15 changed files with 436 additions and 22 deletions

View File

@ -9,6 +9,7 @@
<!-- 앱이 화면에서 내려가도 다운로드 · MKV 만들기 · AI 자막을 계속하고 진행 알림을 보여 주기 위해 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
@ -132,7 +133,11 @@
<service
android:name=".KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync"/>
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps folder live sync, video conversion and downloads running while the app is closed (user setting: run in background)"/>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -12,7 +12,7 @@ import android.os.Build
import android.os.IBinder
import android.os.PowerManager
/// 다운로드 · MKV 만들기 · AI 자막이 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
/// 다운로드 · MKV 만들기 · AI 자막 · 실시간 동기화 (백그라운드로 실행) 가 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
/// 포그라운드 서비스. 알림에 진행 상황을 보여 주고, 화면이 꺼져도 CPU 가 잠들지 않게 한다.
/// 실제 작업은 앱 (Dart) 이 같은 프로세스에서 계속한다. 작업이 모두 끝나면 앱이 멈춘다.
class KeepAliveService : Service() {
@ -25,7 +25,10 @@ class KeepAliveService : Service() {
val progress = intent?.getIntExtra(EXTRA_PROGRESS, -1) ?: -1
val n = notification(this, text, progress)
running = true
if (Build.VERSION.SDK_INT >= 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)
}
)

View File

@ -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<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()
@ -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)

View File

@ -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.",

View File

@ -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}",
"찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。",

View File

@ -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}",
"찾는 즐겨찾기가 없습니다.": "没有匹配的书签。",

View File

@ -57,8 +57,13 @@ class LiveSync extends ChangeNotifier {
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
Timer? _clock;
void start() {
/// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다)
final paused = <String>{};
/// [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<LiveSyncPair> 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<LiveSyncPair> 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<String> 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<void> syncNow(LiveSyncPair x) async {
final k = keyOf(x);

View File

@ -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<String> 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),

View File

@ -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<void> main(List<String> 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<void> main(List<String> 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<void> 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<void> 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<void> 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));

View File

@ -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<void> _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<void>('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<void>('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);
// 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다)

View File

@ -17,8 +17,15 @@ class AndroidShell extends NoopShell {
static const _ch = MethodChannel('jj_mkvmaker/android');
bool _full = false;
/// [종료]: 백그라운드로 살려 둔 엔진 · 작업 알림까지 끝낸다 (화면만 닫는 ← 와 다름)
@override
Future<void> quit() => SystemNavigator.pop();
Future<void> quit() async {
try {
await _ch.invokeMethod<void>('exitApp');
} catch (_) {
await SystemNavigator.pop();
}
}
@override
Future<void> setFullScreen(bool on) async {

View File

@ -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<String>(
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,
),
]);
}
}

View File

@ -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<void> 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<bool>(
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))));
}

View File

@ -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));
});
}

View File

@ -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<void>.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<void>.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<void>.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));