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

- 설정 > 파일 탐색기 > 실시간 동기화 (모니터링 > 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 자막을 계속하고 진행 알림을 보여 주기 위해 --> <!-- 앱이 화면에서 내려가도 다운로드 · MKV 만들기 · AI 자막을 계속하고 진행 알림을 보여 주기 위해 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <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_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 --> <!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
@ -132,7 +133,11 @@
<service <service
android:name=".KeepAliveService" android:name=".KeepAliveService"
android:exported="false" 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. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

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

View File

@ -1,6 +1,7 @@
package com.jj.jj_mkvmaker package com.jj.jj_mkvmaker
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@ -12,6 +13,7 @@ import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDL
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File import java.io.File
@ -20,6 +22,40 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다 /// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS" const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
private const val ENGINE_ID = "main"
/// 백그라운드로 실행 (환경 설정, 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 private var channel: MethodChannel? = null
@ -36,8 +72,30 @@ class MainActivity : FlutterActivity() {
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
channel = ch channel = ch
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 -> ch.setMethodCallHandler { call, result ->
when (call.method) { 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" -> { "takeOpenedFiles" -> {
result.success(pendingOpen) result.success(pendingOpen)
pendingOpen = emptyList() pendingOpen = emptyList()
@ -81,7 +139,7 @@ class MainActivity : FlutterActivity() {
result.success(null) result.success(null)
} }
"stopKeepAlive" -> { "stopKeepAlive" -> {
stopService(Intent(this, KeepAliveService::class.java)) stopService(Intent(applicationContext, KeepAliveService::class.java))
result.success(null) result.success(null)
} }
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() } "downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
@ -345,21 +403,22 @@ class MainActivity : FlutterActivity() {
private var askedNotifications = false private var askedNotifications = false
private fun keepAlive(text: String, progress: Int) { 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 checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) {
askedNotifications = true askedNotifications = true
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
} }
if (KeepAliveService.running) { if (KeepAliveService.running) {
KeepAliveService.update(this, text, progress) KeepAliveService.update(applicationContext, text, progress)
return return
} }
val i = Intent(this, KeepAliveService::class.java) val i = Intent(applicationContext, KeepAliveService::class.java)
.putExtra(KeepAliveService.EXTRA_TEXT, text) .putExtra(KeepAliveService.EXTRA_TEXT, text)
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress) .putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
try { 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) { } catch (e: Exception) {
// 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다 // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
android.util.Log.w("jj_mkvmaker", "keepAlive", e) android.util.Log.w("jj_mkvmaker", "keepAlive", e)

View File

@ -217,6 +217,7 @@
"… 외 {0}개": "… and {0} more", "… 외 {0}개": "… and {0} more",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 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)", "※ 시간이 오래 걸립니다 (영상 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", "− 차갑게 · + 따뜻하게": "− cooler · + warmer",
"⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "Drag ⋮⋮ to reorder · drag the icon into a folder · right-click menu", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "Drag ⋮⋮ to reorder · drag the icon into a folder · right-click menu",
"── 시작 {0} (Android) ──": "── Start {0} (Android) ──", "── 시작 {0} (Android) ──": "── Start {0} (Android) ──",
@ -250,9 +251,11 @@
"계속 편집": "Keep editing", "계속 편집": "Keep editing",
"고르기": "Choose", "고르기": "Choose",
"고르지 않음": "Not chosen", "고르지 않음": "Not chosen",
"고른 것 시작": "Start selected",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)",
"고지 문서": "Notices", "고지 문서": "Notices",
"고화질": "High quality", "고화질": "High quality",
"골라서 시작": "Choose and start",
"관리 메뉴": "Manage menu", "관리 메뉴": "Manage menu",
"광고 건너뛰는 중…": "Skipping ad…", "광고 건너뛰는 중…": "Skipping ad…",
"권한 허용 화면 열기": "Open the permission screen", "권한 허용 화면 열기": "Open the permission screen",
@ -343,6 +346,7 @@
"다시 만들기 대기로": "Back to waiting for remake", "다시 만들기 대기로": "Back to waiting for remake",
"다시 세기": "Recount", "다시 세기": "Recount",
"다시 시작": "Restart", "다시 시작": "Restart",
"다시 켤 때 동기화": "Sync on restart",
"다운로드": "Downloads", "다운로드": "Downloads",
"다운로드 (jj_yt-dlp · jj_aria2)": "Downloads (jj_yt-dlp · jj_aria2)", "다운로드 (jj_yt-dlp · jj_aria2)": "Downloads (jj_yt-dlp · jj_aria2)",
"다운로드 ({0})": "Downloads ({0})", "다운로드 ({0})": "Downloads ({0})",
@ -386,6 +390,8 @@
"덴마크어": "Danish", "덴마크어": "Danish",
"독일어": "German", "독일어": "German",
"돌아가기": "Go back", "돌아가기": "Go back",
"동기화 {0}개 · 맞추는 중": "{0} syncs · syncing",
"동기화 {0}개 지켜보는 중": "Watching {0} syncs",
"동기화 시간": "Sync time", "동기화 시간": "Sync time",
"동기화 시간 (계속 / 시간 지정)": "Sync time (always / scheduled)", "동기화 시간 (계속 / 시간 지정)": "Sync time (always / scheduled)",
"동시 MKV 변환 수": "Simultaneous MKV conversions", "동시 MKV 변환 수": "Simultaneous MKV conversions",
@ -434,14 +440,19 @@
"매번 고르기": "Ask every time", "매번 고르기": "Ask every time",
"매일 종일": "Every day, all day", "매일 종일": "Every day, all day",
"멈춤": "Stopped", "멈춤": "Stopped",
"멈춤 (이번 실행 동안)": "Pause (this session)",
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again",
"멈춤 · {0}": "Stopped · {0}", "멈춤 · {0}": "Stopped · {0}",
"모니터링": "Monitor", "모니터링": "Monitor",
"모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중 {0}%": "Downloading model {0}%",
"모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})",
"모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}", "모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}",
"모두 나중에": "All later",
"모두 내장 플레이어": "Built-in player for all", "모두 내장 플레이어": "Built-in player for all",
"모두 멈춤": "Pause all",
"모두 선택": "Select all", "모두 선택": "Select all",
"모두 시작 (멈춘 것 {0}개)": "Start all ({0} paused)",
"모두 종료": "Quit all", "모두 종료": "Quit all",
"모두 지우기": "Clear all", "모두 지우기": "Clear all",
"모두 초기화": "Reset all", "모두 초기화": "Reset all",
@ -463,6 +474,8 @@
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
"바뀐 날": "Modified", "바뀐 날": "Modified",
"바뀐 내용": "Changes", "바뀐 내용": "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)", "바로 재생 (기본)": "Play right away (default)",
"바로 전 작업: {0}": "Previous job: {0}", "바로 전 작업: {0}": "Previous job: {0}",
"받는 중": "Downloading", "받는 중": "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)", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {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", "백그라운드로": "To background",
"백그라운드로 계속 (기본)": "Keep running in the background (default)", "백그라운드로 계속 (기본)": "Keep running in the background (default)",
"백그라운드로 실행": "Run in background",
"버튼 구성": "Button layout", "버튼 구성": "Button layout",
"번역 모델 내려받는 중 {0}%": "Downloading translation model {0}%", "번역 모델 내려받는 중 {0}%": "Downloading translation model {0}%",
"번역 모델 내려받는 중: {0}": "Downloading translation model: {0}", "번역 모델 내려받는 중: {0}": "Downloading translation model: {0}",
@ -599,12 +613,15 @@
"시작": "Start", "시작": "Start",
"시작 = 현재": "Start = now", "시작 = 현재": "Start = now",
"시작 · 웹 브라우저": "Startup · web browser", "시작 · 웹 브라우저": "Startup · web browser",
"시작 안 함 (모니터링에서 시작)": "Don't start (start from Monitor)",
"시작: {0} → {1}\\{2}{3}": "Started: {0} → {1}\\{2}{3}", "시작: {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)", "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)",
"시작할 때마다 설정 창 보기": "Show the setup window at every start", "시작할 때마다 설정 창 보기": "Show the setup window at every start",
"시즌": "Season", "시즌": "Season",
"실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)", "실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)",
"실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}", "실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}",
"실시간 동기화 시작": "Start live sync",
"실시간 동기화 실패: {0} → {1}: {2}": "Live sync failed: {0} → {1}: {2}", "실시간 동기화 실패: {0} → {1}: {2}": "Live sync failed: {0} → {1}: {2}",
"실시간 동기화 추가": "Add live sync", "실시간 동기화 추가": "Add live sync",
"실시간 동기화: 대상 폴더": "Live sync: target folder", "실시간 동기화: 대상 폴더": "Live sync: target folder",
@ -640,6 +657,7 @@
"앱 안 브라우저 (권장)": "In-app browser (recommended)", "앱 안 브라우저 (권장)": "In-app browser (recommended)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 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)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "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.", "앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window",
"언어": "Language", "언어": "Language",
@ -951,6 +969,7 @@
"창 보이기 / 숨기기 단축키": "Hotkey to show / hide the window", "창 보이기 / 숨기기 단축키": "Hotkey to show / hide the window",
"창 숨김 → 프로세스 종료": "Window hidden → process exit", "창 숨김 → 프로세스 종료": "Window hidden → process exit",
"창 종료": "Close window", "창 종료": "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", "찾기": "Search",
"찾기: {0}": "Search: {0}", "찾기: {0}": "Search: {0}",
"찾는 즐겨찾기가 없습니다.": "No matching bookmarks.", "찾는 즐겨찾기가 없습니다.": "No matching bookmarks.",

View File

@ -217,6 +217,7 @@
"… 외 {0}개": "… ほか {0} 件", "… 외 {0}개": "… ほか {0} 件",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。",
"※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)",
"← 로 닫거나 최근 앱 목록에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속합니다 (알림에 표시). 끄면 지금처럼 닫을 때 끝납니다. 완전히 끝내려면 [종료].": "← で閉じたり最近のアプリから払っても同期 · MKV 作成 · ダウンロードを続けます (通知に表示)。オフにすると今まで通り閉じたときに終了します。完全に終了するには [終了]。",
"− 차갑게 · + 따뜻하게": "− 寒色 · + 暖色", "− 차갑게 · + 따뜻하게": "− 寒色 · + 暖色",
"⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "⋮⋮ をドラッグして並べ替え・アイコンをドラッグしてフォルダーへ・右クリックメニュー", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "⋮⋮ をドラッグして並べ替え・アイコンをドラッグしてフォルダーへ・右クリックメニュー",
"── 시작 {0} (Android) ──": "── 開始 {0}(Android)──", "── 시작 {0} (Android) ──": "── 開始 {0}(Android)──",
@ -250,9 +251,11 @@
"계속 편집": "編集を続ける", "계속 편집": "編集を続ける",
"고르기": "選ぶ", "고르기": "選ぶ",
"고르지 않음": "未選択", "고르지 않음": "未選択",
"고른 것 시작": "選んだものを開始",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)",
"고지 문서": "告知文書", "고지 문서": "告知文書",
"고화질": "高画質", "고화질": "高画質",
"골라서 시작": "選んで開始",
"관리 메뉴": "管理メニュー", "관리 메뉴": "管理メニュー",
"광고 건너뛰는 중…": "広告をスキップ中…", "광고 건너뛰는 중…": "広告をスキップ中…",
"권한 허용 화면 열기": "権限の許可画面を開く", "권한 허용 화면 열기": "権限の許可画面を開く",
@ -343,6 +346,7 @@
"다시 만들기 대기로": "作り直し待ちに戻す", "다시 만들기 대기로": "作り直し待ちに戻す",
"다시 세기": "再カウント", "다시 세기": "再カウント",
"다시 시작": "再起動", "다시 시작": "再起動",
"다시 켤 때 동기화": "再起動時の同期",
"다운로드": "ダウンロード", "다운로드": "ダウンロード",
"다운로드 (jj_yt-dlp · jj_aria2)": "ダウンロード(jj_yt-dlp・jj_aria2)", "다운로드 (jj_yt-dlp · jj_aria2)": "ダウンロード(jj_yt-dlp・jj_aria2)",
"다운로드 ({0})": "ダウンロード({0})", "다운로드 ({0})": "ダウンロード({0})",
@ -386,6 +390,8 @@
"덴마크어": "デンマーク語", "덴마크어": "デンマーク語",
"독일어": "ドイツ語", "독일어": "ドイツ語",
"돌아가기": "戻る", "돌아가기": "戻る",
"동기화 {0}개 · 맞추는 중": "同期 {0} 件 · 同期中",
"동기화 {0}개 지켜보는 중": "同期 {0} 件を監視中",
"동기화 시간": "同期時間", "동기화 시간": "同期時間",
"동기화 시간 (계속 / 시간 지정)": "同期時間 (常時 / 時間指定)", "동기화 시간 (계속 / 시간 지정)": "同期時間 (常時 / 時間指定)",
"동시 MKV 변환 수": "同時 MKV 変換数", "동시 MKV 변환 수": "同時 MKV 変換数",
@ -434,14 +440,19 @@
"매번 고르기": "毎回選ぶ", "매번 고르기": "毎回選ぶ",
"매일 종일": "毎日 終日", "매일 종일": "毎日 終日",
"멈춤": "停止", "멈춤": "停止",
"멈춤 (이번 실행 동안)": "停止 (今回の実行中)",
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します",
"멈춤 · {0}": "停止 · {0}", "멈춤 · {0}": "停止 · {0}",
"모니터링": "モニタリング", "모니터링": "モニタリング",
"모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%",
"모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})",
"모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}", "모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}",
"모두 나중에": "すべて後で",
"모두 내장 플레이어": "すべて内蔵プレーヤー", "모두 내장 플레이어": "すべて内蔵プレーヤー",
"모두 멈춤": "すべて停止",
"모두 선택": "すべて選択", "모두 선택": "すべて選択",
"모두 시작 (멈춘 것 {0}개)": "すべて開始 (停止中 {0} 件)",
"모두 종료": "すべて終了", "모두 종료": "すべて終了",
"모두 지우기": "すべて削除", "모두 지우기": "すべて削除",
"모두 초기화": "すべてリセット", "모두 초기화": "すべてリセット",
@ -463,6 +474,8 @@
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
"바뀐 날": "更新日時", "바뀐 날": "更新日時",
"바뀐 내용": "変更内容", "바뀐 내용": "変更内容",
"바로 시작 (기본)": "すぐ開始 (既定)",
"바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "すぐ開始: オンの同期をすべて開始 · 選んで開始: 起動時に選ぶ · 開始しない: モニタリング > lsync の開始ボタンで",
"바로 재생 (기본)": "すぐ再生(既定)", "바로 재생 (기본)": "すぐ再生(既定)",
"바로 전 작업: {0}": "直前の作業: {0}", "바로 전 작업: {0}": "直前の作業: {0}",
"받는 중": "ダウンロード中", "받는 중": "ダウンロード中",
@ -484,6 +497,7 @@
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)",
"백그라운드로": "バックグラウンドへ", "백그라운드로": "バックグラウンドへ",
"백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)",
"백그라운드로 실행": "バックグラウンドで実行",
"버튼 구성": "ボタン構成", "버튼 구성": "ボタン構成",
"번역 모델 내려받는 중 {0}%": "翻訳モデルをダウンロード中 {0}%", "번역 모델 내려받는 중 {0}%": "翻訳モデルをダウンロード中 {0}%",
"번역 모델 내려받는 중: {0}": "翻訳モデルをダウンロード中: {0}", "번역 모델 내려받는 중: {0}": "翻訳モデルをダウンロード中: {0}",
@ -599,12 +613,15 @@
"시작": "開始", "시작": "開始",
"시작 = 현재": "開始 = 現在", "시작 = 현재": "開始 = 現在",
"시작 · 웹 브라우저": "起動・ウェブブラウザー", "시작 · 웹 브라우저": "起動・ウェブブラウザー",
"시작 안 함 (모니터링에서 시작)": "開始しない (モニタリングで開始)",
"시작: {0} → {1}\\{2}{3}": "開始: {0} → {1}\\{2}{3}", "시작: {0} → {1}\\{2}{3}": "開始: {0} → {1}\\{2}{3}",
"시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "開始する同期を選んでください。選ばなかったものは今回の実行中は停止します (モニタリング > lsync で開始)。",
"시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)",
"시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示",
"시즌": "シーズン", "시즌": "シーズン",
"실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)", "실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)",
"실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}", "실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}",
"실시간 동기화 시작": "リアルタイム同期の開始",
"실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}", "실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}",
"실시간 동기화 추가": "リアルタイム同期を追加", "실시간 동기화 추가": "リアルタイム同期を追加",
"실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー", "실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー",
@ -640,6 +657,7 @@
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱을 다시 켤 때 동기화": "アプリ再起動時の同期",
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。", "앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
"언어": "言語", "언어": "言語",
@ -951,6 +969,7 @@
"창 보이기 / 숨기기 단축키": "ウィンドウ表示 / 非表示のショートカット", "창 보이기 / 숨기기 단축키": "ウィンドウ表示 / 非表示のショートカット",
"창 숨김 → 프로세스 종료": "ウィンドウ非表示 → プロセス終了", "창 숨김 → 프로세스 종료": "ウィンドウ非表示 → プロセス終了",
"창 종료": "ウィンドウを閉じる", "창 종료": "ウィンドウを閉じる",
"창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "ウィンドウを閉じても (✕) トレイで同期 · MKV 作成 · ダウンロードを続けます。オフにすると閉じたときにすべて終了します。(実行 · 終了の「終了を押したら」と同じ設定)",
"찾기": "検索", "찾기": "検索",
"찾기: {0}": "検索: {0}", "찾기: {0}": "検索: {0}",
"찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。", "찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。",

View File

@ -217,6 +217,7 @@
"… 외 {0}개": "… 另外 {0} 个", "… 외 {0}개": "… 另外 {0} 个",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。",
"※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)",
"← 로 닫거나 최근 앱 목록에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속합니다 (알림에 표시). 끄면 지금처럼 닫을 때 끝납니다. 완전히 끝내려면 [종료].": "用 ← 关闭或从最近应用中划掉后仍继续同步 · 制作 MKV · 下载 (在通知中显示)。关闭此项则像以前一样关闭时结束。要完全退出请按 [退出]。",
"− 차갑게 · + 따뜻하게": "− 偏冷 · + 偏暖", "− 차갑게 · + 따뜻하게": "− 偏冷 · + 偏暖",
"⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "拖动 ⋮⋮ 调整顺序 · 拖动图标放入文件夹 · 右键菜单", "⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 메뉴": "拖动 ⋮⋮ 调整顺序 · 拖动图标放入文件夹 · 右键菜单",
"── 시작 {0} (Android) ──": "── 启动 {0}(Android)──", "── 시작 {0} (Android) ──": "── 启动 {0}(Android)──",
@ -250,9 +251,11 @@
"계속 편집": "继续编辑", "계속 편집": "继续编辑",
"고르기": "选择", "고르기": "选择",
"고르지 않음": "未选择", "고르지 않음": "未选择",
"고른 것 시작": "开始所选",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)",
"고지 문서": "声明文件", "고지 문서": "声明文件",
"고화질": "高画质", "고화질": "高画质",
"골라서 시작": "选择后开始",
"관리 메뉴": "管理菜单", "관리 메뉴": "管理菜单",
"광고 건너뛰는 중…": "正在跳过广告…", "광고 건너뛰는 중…": "正在跳过广告…",
"권한 허용 화면 열기": "打开权限授予界面", "권한 허용 화면 열기": "打开权限授予界面",
@ -343,6 +346,7 @@
"다시 만들기 대기로": "改回等待重新制作", "다시 만들기 대기로": "改回等待重新制作",
"다시 세기": "重新统计", "다시 세기": "重新统计",
"다시 시작": "重新启动", "다시 시작": "重新启动",
"다시 켤 때 동기화": "重新启动时同步",
"다운로드": "下载", "다운로드": "下载",
"다운로드 (jj_yt-dlp · jj_aria2)": "下载(jj_yt-dlp · jj_aria2)", "다운로드 (jj_yt-dlp · jj_aria2)": "下载(jj_yt-dlp · jj_aria2)",
"다운로드 ({0})": "下载({0})", "다운로드 ({0})": "下载({0})",
@ -386,6 +390,8 @@
"덴마크어": "丹麦语", "덴마크어": "丹麦语",
"독일어": "德语", "독일어": "德语",
"돌아가기": "返回", "돌아가기": "返回",
"동기화 {0}개 · 맞추는 중": "同步 {0} 个 · 同步中",
"동기화 {0}개 지켜보는 중": "正在监视 {0} 个同步",
"동기화 시간": "同步时间", "동기화 시간": "同步时间",
"동기화 시간 (계속 / 시간 지정)": "同步时间 (持续 / 指定时间)", "동기화 시간 (계속 / 시간 지정)": "同步时间 (持续 / 指定时间)",
"동시 MKV 변환 수": "同时转换 MKV 数", "동시 MKV 변환 수": "同时转换 MKV 数",
@ -434,14 +440,19 @@
"매번 고르기": "每次选择", "매번 고르기": "每次选择",
"매일 종일": "每天 全天", "매일 종일": "每天 全天",
"멈춤": "已停止", "멈춤": "已停止",
"멈춤 (이번 실행 동안)": "暂停 (本次运行期间)",
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步",
"멈춤 · {0}": "已停止 · {0}", "멈춤 · {0}": "已停止 · {0}",
"모니터링": "监控", "모니터링": "监控",
"모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중 {0}%": "正在下载模型 {0}%",
"모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})",
"모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}", "모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}",
"모두 나중에": "全部稍后",
"모두 내장 플레이어": "全部使用内置播放器", "모두 내장 플레이어": "全部使用内置播放器",
"모두 멈춤": "全部暂停",
"모두 선택": "全选", "모두 선택": "全选",
"모두 시작 (멈춘 것 {0}개)": "全部开始 (已暂停 {0} 个)",
"모두 종료": "全部退出", "모두 종료": "全部退出",
"모두 지우기": "全部清除", "모두 지우기": "全部清除",
"모두 초기화": "全部重置", "모두 초기화": "全部重置",
@ -463,6 +474,8 @@
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
"바뀐 날": "修改时间", "바뀐 날": "修改时间",
"바뀐 내용": "更改内容", "바뀐 내용": "更改内容",
"바로 시작 (기본)": "立即开始 (默认)",
"바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "立即开始: 开始所有已启用的同步 · 选择后开始: 启动时选择 · 不开始: 用 监控 > lsync 的开始按钮",
"바로 재생 (기본)": "立即播放(默认)", "바로 재생 (기본)": "立即播放(默认)",
"바로 전 작업: {0}": "上一个任务: {0}", "바로 전 작업: {0}": "上一个任务: {0}",
"받는 중": "正在下载", "받는 중": "正在下载",
@ -484,6 +497,7 @@
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)",
"백그라운드로": "转到后台", "백그라운드로": "转到后台",
"백그라운드로 계속 (기본)": "在后台继续(默认)", "백그라운드로 계속 (기본)": "在后台继续(默认)",
"백그라운드로 실행": "在后台运行",
"버튼 구성": "按钮配置", "버튼 구성": "按钮配置",
"번역 모델 내려받는 중 {0}%": "正在下载翻译模型 {0}%", "번역 모델 내려받는 중 {0}%": "正在下载翻译模型 {0}%",
"번역 모델 내려받는 중: {0}": "正在下载翻译模型: {0}", "번역 모델 내려받는 중: {0}": "正在下载翻译模型: {0}",
@ -599,12 +613,15 @@
"시작": "开始", "시작": "开始",
"시작 = 현재": "开始 = 当前", "시작 = 현재": "开始 = 当前",
"시작 · 웹 브라우저": "启动 · 网页浏览器", "시작 · 웹 브라우저": "启动 · 网页浏览器",
"시작 안 함 (모니터링에서 시작)": "不开始 (在监控中开始)",
"시작: {0} → {1}\\{2}{3}": "开始: {0} → {1}\\{2}{3}", "시작: {0} → {1}\\{2}{3}": "开始: {0} → {1}\\{2}{3}",
"시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).": "请选择要开始的同步。未选的在本次运行期间暂停 (可在 监控 > lsync 中开始)。",
"시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)", "시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)",
"시작할 때마다 설정 창 보기": "每次启动时显示设置窗口", "시작할 때마다 설정 창 보기": "每次启动时显示设置窗口",
"시즌": "季", "시즌": "季",
"실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)", "실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)",
"실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}", "실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}",
"실시간 동기화 시작": "开始实时同步",
"실시간 동기화 실패: {0} → {1}: {2}": "实时同步失败: {0} → {1}: {2}", "실시간 동기화 실패: {0} → {1}: {2}": "实时同步失败: {0} → {1}: {2}",
"실시간 동기화 추가": "添加实时同步", "실시간 동기화 추가": "添加实时同步",
"실시간 동기화: 대상 폴더": "实时同步: 目标文件夹", "실시간 동기화: 대상 폴더": "实时同步: 目标文件夹",
@ -640,6 +657,7 @@
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)", "앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱을 다시 켤 때 동기화": "应用重新启动时同步",
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。", "앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
"언어": "语言", "언어": "语言",
@ -951,6 +969,7 @@
"창 보이기 / 숨기기 단축키": "显示 / 隐藏窗口的快捷键", "창 보이기 / 숨기기 단축키": "显示 / 隐藏窗口的快捷键",
"창 숨김 → 프로세스 종료": "窗口隐藏 → 进程退出", "창 숨김 → 프로세스 종료": "窗口隐藏 → 进程退出",
"창 종료": "关闭窗口", "창 종료": "关闭窗口",
"창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "关闭窗口 (✕) 后在托盘中继续同步 · 制作 MKV · 下载。关闭此项则关闭时全部结束。(与 运行 · 退出 中的“按退出时”为同一设置)",
"찾기": "搜索", "찾기": "搜索",
"찾기: {0}": "搜索: {0}", "찾기: {0}": "搜索: {0}",
"찾는 즐겨찾기가 없습니다.": "没有匹配的书签。", "찾는 즐겨찾기가 없습니다.": "没有匹配的书签。",

View File

@ -57,8 +57,13 @@ class LiveSync extends ChangeNotifier {
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다 /// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
Timer? _clock; Timer? _clock;
void start() { /// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다)
final paused = <String>{};
/// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작)
void start({bool hold = false}) {
instance = this; instance = this;
if (hold) paused.addAll(c.settings.liveSyncPairs.where((x) => x.enabled).map(keyOf));
c.addListener(_apply); c.addListener(_apply);
_apply(); _apply();
_clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock()); _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()); 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() { 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); if (activeNow(x) && (pending[keyOf(x)]?.isNotEmpty ?? false)) _schedule(x);
} }
notifyListeners(); // 남은 시간 표시 notifyListeners(); // 남은 시간 표시
@ -144,7 +176,7 @@ class LiveSync extends ChangeNotifier {
/// 설정이 바뀌면 감시를 다시 꾸린다 /// 설정이 바뀌면 감시를 다시 꾸린다
void _apply() { void _apply() {
final s = c.settings; 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]); final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]);
if (sig == _sig) return; if (sig == _sig) return;
_sig = sig; _sig = sig;
@ -187,6 +219,9 @@ class LiveSync extends ChangeNotifier {
bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x)); bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x));
/// 지금 맞추는 쌍이 있는지
bool get anyRunning => _running.isNotEmpty;
/// 지금 맞추기 (원본 내용 → 대상) /// 지금 맞추기 (원본 내용 → 대상)
Future<void> syncNow(LiveSyncPair x) async { Future<void> syncNow(LiveSyncPair x) async {
final k = keyOf(x); final k = keyOf(x);

View File

@ -322,6 +322,23 @@ class AppSettings {
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30; 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 = []; List<String> explorerPaths = [];
@ -414,6 +431,8 @@ class AppSettings {
'rsyncPath': rsyncPath, 'rsyncPath': rsyncPath,
'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()], 'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()],
'liveSyncIntervalSec': liveSyncIntervalSec, 'liveSyncIntervalSec': liveSyncIntervalSec,
'runInBackground': runInBackground,
'liveSyncOnStart': liveSyncOnStart,
'copyMonitor': copyMonitor, 'copyMonitor': copyMonitor,
'copyTasks': [for (final x in copyTasks) x.toJson()], 'copyTasks': [for (final x in copyTasks) x.toJson()],
'explorerHistory': explorerHistory, 'explorerHistory': explorerHistory,
@ -519,6 +538,8 @@ class AppSettings {
] ]
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600) ..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
..copyMonitor = j['copyMonitor'] == true ..copyMonitor = j['copyMonitor'] == true
..runInBackground = j['runInBackground'] == true
..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto'
..copyTasks = [ ..copyTasks = [
for (final x in (j['copyTasks'] as List?) ?? const []) for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x), 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/explorer_page.dart';
import 'ui/exit_dialog.dart'; import 'ui/exit_dialog.dart';
import 'ui/home_page.dart'; import 'ui/home_page.dart';
import 'ui/monitor_page.dart' show askLiveSyncStart;
import 'ui/player_page.dart'; import 'ui/player_page.dart';
import 'ui/setup_dialog.dart'; import 'ui/setup_dialog.dart';
import 'ui/update_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)])); controller.note(trf('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). ' '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.', [hm(crashed.$1), hm(crashed.$2)]));
} }
await controller.init(); await controller.init();
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안 // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
LiveSync(controller).start(); final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); await i18n.apply(controller.settings.uiLanguage, save: false);
@ -295,6 +296,8 @@ Future<void> main(List<String> args) async {
final nav = navigatorKey.currentState; final nav = navigatorKey.currentState;
if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); 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); await checkRequiredTools(ctx, services.shell);
final ctx2 = navigatorKey.currentContext; final ctx2 = navigatorKey.currentContext;
if (ctx2 != null && ctx2.mounted) await checkForUpdate(ctx2, controller); 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'); final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log');
controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
await controller.init(); await controller.init();
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안 // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
LiveSync(controller).start(); final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); await i18n.apply(controller.settings.uiLanguage, save: false);
@ -414,6 +417,8 @@ Future<void> runAndroid(String dataDir) async {
downloads.log = controller.note; downloads.log = controller.note;
// 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)
AndroidKeepAlive(controller, downloads); AndroidKeepAlive(controller, downloads);
// 백그라운드로 실행: 화면을 닫았다가 다시 열면 새 화면 (Activity) 이 살아 있던 엔진에 붙는다 → 화면 방향을 다시 적용
AppLifecycleListener(onResume: () => applyScreenOrientation(controller.settings.screenOrientation));
// 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일 // 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
@ -452,6 +457,9 @@ Future<void> runAndroid(String dataDir) async {
final nav = navigatorKey.currentState; final nav = navigatorKey.currentState;
if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); 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; final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller)); 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/app_controller.dart';
import '../../app/download_manager.dart'; import '../../app/download_manager.dart';
import '../../app/live_sync.dart';
import '../../services/downloader.dart'; import '../../services/downloader.dart';
import '../../l10n/tr.dart'; import '../../l10n/tr.dart';
/// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt) /// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt)
/// 를 띄워 앱이 화면에서 내려가도 계속하게 하고, 알림에 진행 상황을 보여 준다. 모두 끝나면 내린다. /// 를 띄워 앱이 화면에서 내려가도 계속하게 하고, 알림에 진행 상황을 보여 준다. 모두 끝나면 내린다.
/// [AppSettings.runInBackground] 를 켜면 실시간 동기화를 지켜보는 동안에도 띄워 두고, 앱을 닫아도 (← · 최근 앱에서 밀기)
/// Dart 엔진을 살려 둔다 (MainActivity.setBackground).
class AndroidKeepAlive { class AndroidKeepAlive {
static const _ch = MethodChannel('jj_mkvmaker/android'); static const _ch = MethodChannel('jj_mkvmaker/android');
@ -21,18 +24,37 @@ class AndroidKeepAlive {
AndroidKeepAlive(this.c, this.downloads) { AndroidKeepAlive(this.c, this.downloads) {
c.addListener(_changed); c.addListener(_changed);
downloads.addListener(_changed); downloads.addListener(_changed);
_changed();
} }
LiveSync? _live;
bool? _background;
/// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다 /// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다
void _changed() => _timer ??= Timer(const Duration(seconds: 1), _apply); void _changed() => _timer ??= Timer(const Duration(seconds: 1), _apply);
Future<void> _apply() async { Future<void> _apply() async {
_timer = null; _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 final running = downloads.tasks
.where((t) => t.state == DownloadState.downloading || t.state == DownloadState.queued) .where((t) => t.state == DownloadState.downloading || t.state == DownloadState.queued)
.length; .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 { try {
if (text == null) { if (text == null) {
if (_on) await _ch.invokeMethod<void>('stopKeepAlive'); if (_on) await _ch.invokeMethod<void>('stopKeepAlive');
@ -49,10 +71,13 @@ class AndroidKeepAlive {
} }
/// 알림 글과 진행률 (0~100, 모르면 -1). 진행 중인 일이 없으면 글이 null. /// 알림 글과 진행률 (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 = [ final parts = [
if (busy) job ?? tr('작업 중'), if (busy) job ?? tr('작업 중'),
if (downloads > 0) trf('다운로드 {0}개{1}', [downloads, downloadProgress == null ? '' : ' · ${(downloadProgress * 100).round()}%']), 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); if (parts.isEmpty) return (null, -1);
// 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다) // 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다)

View File

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

View File

@ -286,6 +286,7 @@ class _LiveSyncTile extends StatelessWidget {
label: Text(tr('추가')), label: Text(tr('추가')),
), ),
), ),
Padding(padding: const EdgeInsets.only(left: 16), child: LiveSyncRunOptions(c: c)),
for (var i = 0; i < pairs.length; i++) for (var i = 0; i < pairs.length; i++)
Padding( Padding(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
@ -336,3 +337,66 @@ class _LiveSyncTile extends StatelessWidget {
return live == null ? body() : ListenableBuilder(listenable: live, builder: (_, _) => body()); 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 '../core/sync_tools.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'app_actions.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 'rsync_setup.dart';
import 'schedule_editor.dart'; import 'schedule_editor.dart';
import 'theme.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('추가'))), 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), const SizedBox(height: 8),
if (pairs.isEmpty) if (pairs.isEmpty)
Padding( Padding(
@ -461,6 +473,7 @@ class _LiveTabState extends State<_LiveTab> {
final st = live?.status[k]; final st = live?.status[k];
final active = LiveSync.activeNow(x); final active = LiveSync.activeNow(x);
final running = live?.isRunning(x) ?? false; final running = live?.isRunning(x) ?? false;
final held = live?.isPaused(x) ?? false;
return Card( return Card(
color: JjColors.panel, color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
@ -468,8 +481,17 @@ class _LiveTabState extends State<_LiveTab> {
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10), padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Icon(running ? Icons.sync : x.enabled ? (active ? Icons.sync : Icons.bedtime_outlined) : Icons.sync_disabled, Icon(
color: x.enabled ? (active ? JjColors.accent : Colors.orangeAccent) : JjColors.textDim), 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), const SizedBox(width: 8),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
@ -481,8 +503,21 @@ class _LiveTabState extends State<_LiveTab> {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')), 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))), 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(
padding: const EdgeInsets.only(left: 32, top: 4), padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ 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) 이 있으면 글만 // 작업 (MKV · AI) 이 있으면 글만
expect(AndroidKeepAlive.status('MKV 만들기', true, 0, null), ('MKV 만들기', -1)); expect(AndroidKeepAlive.status('MKV 만들기', true, 0, null), ('MKV 만들기', -1));
expect(AndroidKeepAlive.status('AI 자막', true, 1, 0.5), ('AI 자막 · 다운로드 1개 · 50%', -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 { testWidgets('모니터링 화면: 복사 목록 · 옵션 고치기 → 현재 유지 / 반영 후 실행, lsync 탭 · 일정', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_monui_'); final tmp = Directory.systemTemp.createTempSync('jj_monui_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));