diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 628ec15..1d110e3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -570,6 +570,8 @@ "날짜": "Date", "남는 것": "What stays", "남은 {0}장": "{0} left", + "남은 것 모두 취소 ({0}개)": "Cancel all remaining ({0})", + "남은 것: MKV 만들기 {0}개{1}": "Remaining: {0} MKV{1}", "남은 공간 {0}": "{0} free", "남은 시간 {0}": "{0} left", "남은 약 {0}": "about {0} left", @@ -1328,6 +1330,7 @@ "완료": "Done", "완료 정리": "Clear completed", "완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total", + "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "Done: {0} succeeded · {1} cancelled · {2} failed / {3} total", "완료시 자동 동영상추가": "Auto-add videos when done", "외부": "External", "외부 브라우저": "External browser", @@ -1745,6 +1748,8 @@ "즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)", "지금": "Current", "지금 {0}%": "Now {0}%", + "지금 것만 취소 (다음 것부터 이어서)": "Cancel only the current one (continue with the next)", + "지금 것만 취소: {0}": "Cancelled only the current one: {0}", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?", "지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password", "지금 맞추기": "Sync now", @@ -1855,6 +1860,7 @@ "취소": "Cancel", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)", "취소됨": "Cancelled", + "취소됨: {0}": "Cancelled: {0}", "취소했습니다": "Cancelled", "취소했습니다.": "Cancelled.", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index aa713d7..9a720f1 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -570,6 +570,8 @@ "날짜": "日付", "남는 것": "残るもの", "남은 {0}장": "残り {0} 枚", + "남은 것 모두 취소 ({0}개)": "残りをすべて取り消し ({0} 件)", + "남은 것: MKV 만들기 {0}개{1}": "残り: MKV 作成 {0} 件{1}", "남은 공간 {0}": "空き容量 {0}", "남은 시간 {0}": "残り時間 {0}", "남은 약 {0}": "残り約 {0}", @@ -1328,6 +1330,7 @@ "완료": "完了", "완료 정리": "完了を整理", "완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}", + "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完了: 成功 {0} · 取消 {1} · 失敗 {2} / 全 {3}", "완료시 자동 동영상추가": "完了時に動画を自動追加", "외부": "外部", "외부 브라우저": "外部ブラウザー", @@ -1745,6 +1748,8 @@ "즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)", "지금": "現在", "지금 {0}%": "現在 {0}%", + "지금 것만 취소 (다음 것부터 이어서)": "今のものだけ取り消し (次から続ける)", + "지금 것만 취소: {0}": "今のものだけ取り消し: {0}", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", "지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード", "지금 맞추기": "今すぐ同期", @@ -1855,6 +1860,7 @@ "취소": "キャンセル", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)", "취소됨": "キャンセル済み", + "취소됨: {0}": "取り消し: {0}", "취소했습니다": "キャンセルしました", "취소했습니다.": "キャンセルしました。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index bfa536a..3880a36 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -570,6 +570,8 @@ "날짜": "日期", "남는 것": "保留的内容", "남은 {0}장": "剩余 {0} 张", + "남은 것 모두 취소 ({0}개)": "取消全部剩余项({0} 个)", + "남은 것: MKV 만들기 {0}개{1}": "剩余:MKV 制作 {0} 个{1}", "남은 공간 {0}": "剩余空间 {0}", "남은 시간 {0}": "剩余时间 {0}", "남은 약 {0}": "剩余约 {0}", @@ -1328,6 +1330,7 @@ "완료": "完成", "완료 정리": "清理已完成", "완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}", + "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完成:成功 {0} · 取消 {1} · 失败 {2} / 共 {3}", "완료시 자동 동영상추가": "完成后自动添加视频", "외부": "外部", "외부 브라우저": "外部浏览器", @@ -1745,6 +1748,8 @@ "즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)", "지금": "当前", "지금 {0}%": "当前 {0}%", + "지금 것만 취소 (다음 것부터 이어서)": "仅取消当前项(从下一个继续)", + "지금 것만 취소: {0}": "仅取消当前项:{0}", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", "지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)", "지금 맞추기": "立即同步", @@ -1855,6 +1860,7 @@ "취소": "取消", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)", "취소됨": "已取消", + "취소됨: {0}": "已取消:{0}", "취소했습니다": "已取消", "취소했습니다.": "已取消。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 9e9922b..ca724ef 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1328,25 +1328,51 @@ class AppController extends ChangeNotifier { if (queue.isEmpty) return; final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs; if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)])); - var next = 0; + _batchQueue = queue; + _batchNext = 0; Future worker() async { - while (!_buildCancelled && next < queue.length) { - await _build(queue[next++]); + while (!_buildCancelled && _batchNext < queue.length) { + await _build(queue[_batchNext++]); } } await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); - // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음) - final ok = queue.where((v) => v.status == JobStatus.done).length; - _log(trf('완료: 성공 {0} / 전체 {1}', [ok, queue.length])); + // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 + int count(JobStatus s) => queue.where((v) => v.status == s).length; + _log(trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', + [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); } finally { buildingMkv = false; + _batchQueue = const []; + _batchNext = 0; notifyListeners(); } } + /// 186: 지금 MKV 만들기 묶음 (차례를 기다리는 동영상도 "대기" 로 센다) + List _batchQueue = const []; + int _batchNext = 0; + + /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 + int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0; + + /// 186: 지금 만드는 동영상 이름 (취소 창에 보일 것) + List get runningVideoNames => [for (final v in videos) if (v.status == JobStatus.running) v.fileName]; + + /// 186 · 187: 취소 요청 차례 - 만들던 중에 바뀌었으면 그 동영상은 "취소됨" + int _cancelGen = 0; + + /// 186: 지금 만드는 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업도 그대로) + void cancelRunningVideos() { + if (!buildingMkv) return cancelCurrent(); + _log(trf('지금 것만 취소: {0}', [runningVideoNames.join(', ')])); + _cancelGen++; + _tool.cancel(); + } + Future _build(VideoItem v) async { final out = outputMkvPath(v.path); + final gen = _cancelGen; v ..status = JobStatus.running ..progress = 0 @@ -1386,10 +1412,12 @@ class AppController extends ChangeNotifier { : e is ArgumentError ? '${e.message}' : '$e'; + // 187: 사용자가 취소한 것은 실패가 아니라 "취소됨" + final cancelled = gen != _cancelGen || _buildCancelled; v - ..status = JobStatus.failed - ..message = msg; - _log(trf('실패: {0}\n{1}', [v.fileName, msg])); + ..status = cancelled ? JobStatus.cancelled : JobStatus.failed + ..message = cancelled ? tr('취소됨') : msg; + _log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg])); // 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다. if (started) { try { @@ -1418,6 +1446,7 @@ class AppController extends ChangeNotifier { /// 45: 지금 작업만 멈추고 대기 중인 작업은 이어서 (다음 작업이 시작하면 취소 표시는 다시 꺼진다) void cancelCurrent() { if (!busy) return; + _cancelGen++; _log(trf('지금 작업 취소: {0}', [currentJob ?? ''])); _recognizer?.cancel(); _buildCancelled = true; @@ -1427,6 +1456,7 @@ class AppController extends ChangeNotifier { } void cancel() { + _cancelGen++; _recognizer?.cancel(); if (pendingJobs.isNotEmpty) _log(trf('대기 중인 작업 {0}개 취소', [pendingJobs.length])); pendingJobs.clear(); diff --git a/lib/core/models.dart b/lib/core/models.dart index 0769a9b..607f603 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -84,7 +84,8 @@ class SubtitleEntry { : trf('트랙 #{0} ({1}){2}', [streamIndex, codec, title != null ? ' - $title' : '']); } -enum JobStatus { ready, running, done, failed } +/// [cancelled] (187): 사용자가 취소함 - 실패가 아니다 (회색 "취소됨", 작업 기록에서 실패로 세지 않음) +enum JobStatus { ready, running, done, failed, cancelled } class VideoItem { final String path; diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index fe9dc18..a887a5b 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -135,12 +135,44 @@ class _TopBar extends StatelessWidget { /// 고른 동영상이 없을 때: 목록 전체를 만들지 묻는다 /// 보고 있는 동영상의 결과 폴더 (jj_mkv) 를 탐색기 · 파일 앱으로. 아직 없으면 알린다. /// 45: 대기 중인 작업이 있으면 지금 작업만 / 모두 고르게 한다 (없으면 지금 작업을 바로 멈춤 - 예전처럼) + /// 186: 한 묶음 안의 차례를 기다리는 동영상 (MKV 만들기 3개 중 v2 · v3) 도 대기로 센다 - 남은 것이 있으면 + /// "지금 것만 취소 · 남은 것 모두 취소 · 그대로 두기" 를 묻는다 (예전에는 묻지 않고 모두 멈췄다) Future _cancelJobs(BuildContext context) async { - final waiting = c.pendingJobs.length; + final inBatch = c.batchWaiting; + final waiting = c.pendingJobs.length + inBatch; if (waiting == 0) { c.cancel(); return; } + if (inBatch > 0) { + final now = c.runningVideoNames.join(', '); + final pick = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + actionsOverflowDirection: VerticalDirection.down, // 110: [그대로 두기] 가 먼저 + title: Text(tr('작업 취소')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('지금: {0}', [now.isEmpty ? (c.currentJob ?? '') : now])), + const SizedBox(height: 4), + Text(trf('남은 것: MKV 만들기 {0}개{1}', [inBatch, c.pendingJobs.isEmpty ? '' : ' · ${c.pendingJobs.join(' · ')}']), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'current'), child: Text(tr('지금 것만 취소 (다음 것부터 이어서)'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, 'all'), + child: Text(trf('남은 것 모두 취소 ({0}개)', [waiting])), + ), + ], + ), + ); + if (pick == 'current') c.cancelRunningVideos(); + if (pick == 'all') c.cancel(); + return; + } final pick = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -798,7 +830,11 @@ class _VideoTile extends StatelessWidget { Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim)) else Text( - v.status == JobStatus.failed ? tr('실패') : trf('자막 {0}개', [subs]), + v.status == JobStatus.failed + ? tr('실패') + : v.status == JobStatus.cancelled + ? tr('취소됨') + : trf('자막 {0}개', [subs]), style: TextStyle( fontSize: 11, color: v.status == JobStatus.failed @@ -808,7 +844,7 @@ class _VideoTile extends StatelessWidget { ], ), ), - if (v.status == JobStatus.done || v.status == JobStatus.failed) + if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled) IconButton( tooltip: tr('다시 만들기 대기로'), iconSize: 16, @@ -842,6 +878,9 @@ class _StatusIcon extends StatelessWidget { const Icon(Icons.check_circle, size: 20, color: JjColors.success), JobStatus.failed => const Icon(Icons.error, size: 20, color: JjColors.danger), + // 187: 취소는 실패가 아니라 회색 + JobStatus.cancelled => + const Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim), }; } diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 2b83a88..b8f2453 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -261,6 +261,7 @@ class _VideoRow extends StatelessWidget { JobStatus.running => (Icons.autorenew, JjColors.accent), JobStatus.done => (Icons.check_circle, JjColors.success), JobStatus.failed => (Icons.error, JjColors.danger), + JobStatus.cancelled => (Icons.cancel_outlined, JjColors.textDim), // 187 }; final sub = v.status == JobStatus.running ? '${v.phase ?? ''} ${(v.progress * 100).round()}%' @@ -268,6 +269,7 @@ class _VideoRow extends StatelessWidget { switch (v.status) { JobStatus.done => tr('MKV 완성'), JobStatus.failed => trf('실패: {0}', [v.message ?? '']), + JobStatus.cancelled => tr('취소됨'), _ => trf('자막 {0}개', [v.subtitles.where((s) => s.enabled).length]), }; return Padding( diff --git a/test/batch_test.dart b/test/batch_test.dart index b7b73e6..bce52ff 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/app/download_manager.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/core/encode_options.dart'; import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/output_paths.dart'; import 'package:jj_mkvmaker/core/srt.dart'; @@ -188,6 +189,39 @@ void main() { expect(one.status, JobStatus.done); }); + test('186 · 187: MKV 3개 중 v1 을 만드는 동안 "지금 것만 취소" - v1 은 취소됨 (실패 아님), v2 · v3 는 이어서 완성', () async { + final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); + final dir = Directory.systemTemp.createTempSync('jj_batch186_'); + addTearDown(() => dir.deleteSync(recursive: true)); + // v1 은 다시 인코딩에 시간이 걸리게 길게, v2 · v3 는 짧게 + final files = [for (final n in ['v1', 'v2', 'v3']) p.join(dir.path, '$n.mp4')]; + for (final (i, f) in files.indexed) { + await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', i == 0 ? '60' : '1', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]); + } + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + await c.init(); + c.settings.maxParallelJobs = 1; + c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); + await c.addVideos(files); + final [v1, v2, v3] = c.videos; + final building = c.buildVideos([v1, v2, v3]); + // v1 이 만들어지는 중 · 묶음에 2개가 기다림 + for (var i = 0; i < 400 && !(v1.status == JobStatus.running && v1.progress > 0.02); i++) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(v1.status, JobStatus.running); + expect(c.batchWaiting, 2, reason: '한 묶음 안의 기다리는 동영상도 대기로 센다'); + c.cancelRunningVideos(); + await building; + expect([v1.status, v2.status, v3.status], [JobStatus.cancelled, JobStatus.done, JobStatus.done], + reason: '${v1.message} ${v2.message} ${v3.message}'); + expect(v1.message, '취소됨'); + expect(File(outputMkvPath(v1.path)).existsSync(), isFalse, reason: '만들다 만 파일은 지움'); + expect(c.logs.join('\n'), contains('성공 2 · 취소 1 · 실패 0 / 전체 3')); + }); + testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 tester.view.devicePixelRatio = 1;