diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 24f9306..093ad22 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
+
+
+
= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ startForeground(NOTIFICATION_ID, n)
+ }
+ if (wakeLock == null) {
+ wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jj_mkvmaker:jobs")
+ .apply { acquire(6 * 60 * 60 * 1000L) }
+ }
+ return START_NOT_STICKY
+ }
+
+ /// Android 15 부터 dataSync 서비스는 하루 6시간까지 → 시간이 다 되면 멈춘다 (작업은 앱이 화면에 있으면 계속)
+ override fun onTimeout(startId: Int, fgsType: Int) {
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ running = false
+ wakeLock?.let { if (it.isHeld) it.release() }
+ wakeLock = null
+ super.onDestroy()
+ }
+
+ companion object {
+ const val CHANNEL_ID = "jobs"
+ const val NOTIFICATION_ID = 1
+ const val EXTRA_TEXT = "text"
+ const val EXTRA_PROGRESS = "progress"
+
+ /// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
+ @Volatile
+ var running = false
+
+ fun update(context: Context, text: String, progress: Int) {
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.notify(NOTIFICATION_ID, notification(context, text, progress))
+ }
+
+ private fun notification(context: Context, text: String, progress: Int): Notification {
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ 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 자막 진행 상황"
+ setShowBadge(false)
+ }
+ )
+ }
+ val open = PendingIntent.getActivity(
+ context, 0,
+ Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(context, CHANNEL_ID)
+ } else {
+ @Suppress("DEPRECATION") Notification.Builder(context)
+ }
+ b.setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle("JJ_MKVMaker 작업 중")
+ .setContentText(text)
+ .setContentIntent(open)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ if (progress in 0..100) b.setProgress(100, progress, false)
+ return b.build()
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt
index ec5a36d..fd176aa 100644
--- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt
@@ -27,6 +27,14 @@ class MainActivity : FlutterActivity() {
result.success(null)
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
+ "keepAlive" -> {
+ keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1)
+ result.success(null)
+ }
+ "stopKeepAlive" -> {
+ stopService(Intent(this, KeepAliveService::class.java))
+ result.success(null)
+ }
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
"downloadToolsUpdate" -> inBackground(result) {
YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: ""
@@ -36,6 +44,31 @@ class MainActivity : FlutterActivity() {
}
}
+ /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
+ private var askedNotifications = false
+
+ private fun keepAlive(text: String, progress: Int) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications &&
+ 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)
+ return
+ }
+ val i = Intent(this, 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)
+ } catch (e: Exception) {
+ // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
+ android.util.Log.w("jj_mkvmaker", "keepAlive", e)
+ }
+ }
+
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
private fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
Thread {
diff --git a/lib/core/download_detect.dart b/lib/core/download_detect.dart
index dd31278..dd986a8 100644
--- a/lib/core/download_detect.dart
+++ b/lib/core/download_detect.dart
@@ -157,10 +157,14 @@ enum YtQuality {
}
/// yt-dlp 형식 인수
-List ytDlpFormatArgs(YtContainer c, YtQuality q) {
+///
+/// [preferH264]: 같은 크기면 H.264 를 먼저 고른다 (Android: 휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 못 푸는 일이 많아
+/// 재생이 끊기고 배터리를 많이 씀). YouTube 의 H.264 는 1080p 까지라 그보다 큰 화질을 고르면 크기가 먼저다.
+List ytDlpFormatArgs(YtContainer c, YtQuality q, {bool preferH264 = false}) {
final res = q.height == null ? '' : 'res:${q.height},';
+ final codec = preferH264 ? 'vcodec:h264,' : '';
return switch (c) {
- YtContainer.mp4 => ['-S', '${res}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'],
+ YtContainer.mp4 => ['-S', '$res${codec}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'],
YtContainer.webm => ['-S', '${res}ext:webm:webm', '--merge-output-format', 'webm'],
YtContainer.mp3 => ['-f', 'ba/b', '-x', '--audio-format', 'mp3', '--audio-quality', '0'],
YtContainer.m4a => ['-f', 'ba[ext=m4a]/ba/b', '-x', '--audio-format', 'm4a'],
diff --git a/lib/main.dart b/lib/main.dart
index b291dc1..df3c773 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -15,6 +15,7 @@ import 'app/settings.dart';
import 'core/app_update.dart';
import 'core/playlist.dart';
import 'platform/android/android_download_tools.dart';
+import 'platform/android/android_keep_alive.dart';
import 'platform/android/android_storage.dart';
import 'platform/windows/app_paths.dart';
import 'platform/windows/cef_runtime.dart';
@@ -355,6 +356,8 @@ Future runAndroid(String dataDir) async {
};
downloads.addToEditList = controller.addDownloaded;
downloads.log = controller.note;
+ // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)
+ AndroidKeepAlive(controller, downloads);
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (controller.settings.startScreen == 'browser') {
@@ -454,7 +457,19 @@ class JjCapCutApp extends StatelessWidget {
onExit: onExit,
downloads: downloads,
bookmarks: bookmarks,
- child: AppDropArea(c: controller, child: UiScaler(c: controller, child: child!))),
+ child: AppDropArea(c: controller, child: _SystemBarsArea(child: UiScaler(c: controller, child: child!)))),
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
);
}
+
+/// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본)
+/// 위쪽 버튼 줄이 시계 · 배터리 표시와 겹치지 않게 그만큼 띄운다. 전체 화면 재생 중에는 표시줄이 숨어 여백도 없다.
+class _SystemBarsArea extends StatelessWidget {
+ final Widget child;
+ const _SystemBarsArea({required this.child});
+
+ @override
+ Widget build(BuildContext context) => Platform.isAndroid
+ ? ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: SafeArea(child: child))
+ : child;
+}
diff --git a/lib/platform/android/android_download_tools.dart b/lib/platform/android/android_download_tools.dart
index 62d9843..d322984 100644
--- a/lib/platform/android/android_download_tools.dart
+++ b/lib/platform/android/android_download_tools.dart
@@ -70,7 +70,7 @@ class AndroidDownloadTools {
// --ffmpeg-location 은 파일 경로도 받는다 (libffmpeg.so = ffmpeg 실행 파일)
ffmpegDir: t.ffmpeg,
jsRuntime: 'quickjs:${t.quickjs}',
- formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality),
+ formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality, preferH264: true),
cookieArgs: () => ytDlpCookieArgs(
browser: s().ytCookiesBrowser,
file: s().ytCookiesFile,
diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart
new file mode 100644
index 0000000..96b134d
--- /dev/null
+++ b/lib/platform/android/android_keep_alive.dart
@@ -0,0 +1,61 @@
+import 'dart:async';
+
+import 'package:flutter/services.dart';
+
+import '../../app/app_controller.dart';
+import '../../app/download_manager.dart';
+import '../../services/downloader.dart';
+
+/// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt)
+/// 를 띄워 앱이 화면에서 내려가도 계속하게 하고, 알림에 진행 상황을 보여 준다. 모두 끝나면 내린다.
+class AndroidKeepAlive {
+ static const _ch = MethodChannel('jj_mkvmaker/android');
+
+ final AppController c;
+ final DownloadManager downloads;
+ Timer? _timer;
+ bool _on = false;
+ String _last = '';
+
+ AndroidKeepAlive(this.c, this.downloads) {
+ c.addListener(_changed);
+ downloads.addListener(_changed);
+ }
+
+ /// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다
+ void _changed() => _timer ??= Timer(const Duration(seconds: 1), _apply);
+
+ Future _apply() async {
+ _timer = null;
+ // 일시정지한 다운로드는 세지 않는다 (받는 중 · 대기 중만)
+ 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);
+ try {
+ if (text == null) {
+ if (_on) await _ch.invokeMethod('stopKeepAlive');
+ _on = false;
+ _last = '';
+ return;
+ }
+ final key = '$text|$progress';
+ if (_on && key == _last) return;
+ await _ch.invokeMethod('keepAlive', {'text': text, 'progress': progress});
+ _on = true;
+ _last = key;
+ } catch (_) {}
+ }
+
+ /// 알림 글과 진행률 (0~100, 모르면 -1). 진행 중인 일이 없으면 글이 null.
+ static (String?, int) status(String? job, bool busy, int downloads, double? downloadProgress) {
+ final parts = [
+ if (busy) job ?? '작업 중',
+ if (downloads > 0) '다운로드 $downloads개${downloadProgress == null ? '' : ' · ${(downloadProgress * 100).round()}%'}',
+ ];
+ if (parts.isEmpty) return (null, -1);
+ // 진행률 막대는 다운로드만 있을 때 (작업 진행률은 동영상마다 따로라 하나로 합치기 어렵다)
+ final p = !busy && downloadProgress != null ? (downloadProgress * 100).round().clamp(0, 100) : -1;
+ return (parts.join(' · '), p);
+ }
+}
diff --git a/lib/platform/common/nllb_translator.dart b/lib/platform/common/nllb_translator.dart
index fbed2ef..551c133 100644
--- a/lib/platform/common/nllb_translator.dart
+++ b/lib/platform/common/nllb_translator.dart
@@ -11,6 +11,7 @@ import 'package:path/path.dart' as p;
import '../../core/nllb_tokenizer.dart';
import '../../services/ai_services.dart';
+import 'onnx_external.dart';
import 'ort_tensors.dart';
/// NLLB-200 (ONNX, int8) 로컬 번역기. Windows·Android 공용.
@@ -42,7 +43,7 @@ class NllbTranslator implements Translator {
Future load(String modelDir) async {
if (_isolate != null) return;
for (final f in files) {
- if (!File(p.join(modelDir, f)).existsSync()) {
+ if (!File(p.join(modelDir, f)).existsSync() && !OnnxExternal.isReady(p.join(modelDir, f))) {
throw FileSystemException('번역 모델 파일이 없습니다', p.join(modelDir, f));
}
}
@@ -164,18 +165,16 @@ void _workerMain(List