diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3bb26fe..568fdd3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -323,6 +323,7 @@ "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} · {1}배 · {2}": "{0} · {1}× · {2}", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", + "{0} 는 이미 끝났습니다": "{0} has already finished", "{0} 라이선스": "{0} license", "{0} 로 번역 중 {1}%": "Translating to {0} {1}%", "{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{1} characters that cannot be represented in {0} will become \"?\".\nSave anyway? (UTF-8 is recommended)", @@ -857,6 +858,7 @@ "모두 종료": "Quit all", "모두 지우기": "Clear all", "모두 초기화": "Reset all", + "모두 취소 ({0}개)": "Cancel all ({0})", "모두 취소 (대기 {0}개 포함)": "Cancel all (including {0} waiting)", "모두 풀기": "Extract all", "모든 작업 취소": "Cancel all jobs", @@ -1454,6 +1456,7 @@ "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable", "이 기기의 폴더": "Folder on this device", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)", + "이 동영상만 취소": "Cancel only this video", "이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "These two folders are being synced with rsync right now. Try again when it finishes.", "이 버전 건너뛰기": "Skip this version", "이 버전에는 Android 용 APK 가 없습니다.": "This version has no APK for Android.", @@ -2010,6 +2013,7 @@ "필요한 것 모두 받기": "Download everything needed", "필요한 공간 {0}": "Space needed {0}", "필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}", + "하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "To stop one at a time, press ■ on its row in the list.", "하위 폴더가 없습니다": "No subfolders", "한 번 더": "Once more", "한 번: 바로 실행": "Single: open right away", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b541991..74e16cb 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -323,6 +323,7 @@ "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", + "{0} 는 이미 끝났습니다": "{0} はすでに終わりました", "{0} 라이선스": "{0} ライセンス", "{0} 로 번역 중 {1}%": "{0} に翻訳中 {1}%", "{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{0} で表現できない文字 {1}個が「?」に変わります。\n保存を続けますか?(UTF-8 を推奨します)", @@ -857,6 +858,7 @@ "모두 종료": "すべて終了", "모두 지우기": "すべて削除", "모두 초기화": "すべてリセット", + "모두 취소 ({0}개)": "すべてキャンセル ({0} 件)", "모두 취소 (대기 {0}개 포함)": "すべてキャンセル (待機 {0} 件を含む)", "모두 풀기": "すべて展開", "모든 작업 취소": "すべての作業をキャンセル", @@ -1454,6 +1456,7 @@ "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", "이 기기의 폴더": "この端末のフォルダー", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)", + "이 동영상만 취소": "この動画だけキャンセル", "이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "この 2 つのフォルダーは今 rsync 中です。終わってから行ってください。", "이 버전 건너뛰기": "このバージョンをスキップ", "이 버전에는 Android 용 APK 가 없습니다.": "このバージョンには Android 用 APK がありません。", @@ -2010,6 +2013,7 @@ "필요한 것 모두 받기": "必要なものをすべてダウンロード", "필요한 공간 {0}": "必要な容量 {0}", "필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}", + "하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "1 つずつ止めるには、一覧の行の ■ を押してください。", "하위 폴더가 없습니다": "サブフォルダーがありません", "한 번 더": "もう一度", "한 번: 바로 실행": "1 回: すぐに開く", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 06d62e0..1037c7c 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -323,6 +323,7 @@ "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", + "{0} 는 이미 끝났습니다": "{0} 已经完成", "{0} 라이선스": "{0} 许可证", "{0} 로 번역 중 {1}%": "正在翻译为 {0} {1}%", "{0} 로 표현할 수 없는 글자 {1}개가 \"?\" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)": "{1} 个无法用 {0} 表示的字符将变为“?”。\n仍要保存吗?(推荐使用 UTF-8)", @@ -857,6 +858,7 @@ "모두 종료": "全部退出", "모두 지우기": "全部清除", "모두 초기화": "全部重置", + "모두 취소 ({0}개)": "全部取消({0} 个)", "모두 취소 (대기 {0}개 포함)": "全部取消 (包括等待中的 {0} 个)", "모두 풀기": "全部解压", "모든 작업 취소": "取消所有任务", @@ -1454,6 +1456,7 @@ "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", "이 기기의 폴더": "本设备上的文件夹", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)", + "이 동영상만 취소": "仅取消此视频", "이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "这两个文件夹正在进行 rsync。请等完成后再操作。", "이 버전 건너뛰기": "跳过此版本", "이 버전에는 Android 용 APK 가 없습니다.": "此版本没有 Android 版 APK。", @@ -2010,6 +2013,7 @@ "필요한 것 모두 받기": "下载全部所需文件", "필요한 공간 {0}": "所需空间 {0}", "필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}", + "하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "要逐个停止,请按列表行上的 ■。", "하위 폴더가 없습니다": "没有子文件夹", "한 번 더": "再输入一次", "한 번: 바로 실행": "单击: 立即打开", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index ca724ef..19f991c 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1356,18 +1356,28 @@ class AppController extends ChangeNotifier { /// 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: 지금 만드는 동영상 (취소 창에 보일 것) + List get runningVideos => [for (final v in videos) if (v.status == JobStatus.running) v]; /// 186 · 187: 취소 요청 차례 - 만들던 중에 바뀌었으면 그 동영상은 "취소됨" int _cancelGen = 0; - /// 186: 지금 만드는 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업도 그대로) - void cancelRunningVideos() { - if (!buildingMkv) return cancelCurrent(); - _log(trf('지금 것만 취소: {0}', [runningVideoNames.join(', ')])); - _cancelGen++; - _tool.cancel(); + /// 239 · 240: 사용자가 취소하라고 고른 동영상 (만들던 것이 끝나면 "취소됨") + final _cancelledVideos = {}; + + /// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업 · 동시에 도는 다른 동영상도 그대로). + /// 취소 창을 연 때의 대상을 넘긴다 - 그사이 끝나고 다음 것이 시작했으면 그 다음 것을 취소하지 않는다. + /// 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것 + List cancelVideos(List targets) { + final live = [for (final v in targets) if (v.status == JobStatus.running) v]; + if (live.isNotEmpty) { + _log(trf('지금 것만 취소: {0}', [live.map((v) => v.fileName).join(', ')])); + for (final v in live) { + _cancelledVideos.add(v); + _tool.cancelTagged(v); + } + } + return [for (final v in targets) if (!live.contains(v)) v]; } Future _build(VideoItem v) async { @@ -1393,9 +1403,12 @@ class AppController extends ChangeNotifier { final c = await _utf8Copy(s); if (c != null) copies[s] = c; } + // 239: FFmpeg 가 시작하기 전에 취소됐으면 (자막 준비 중) 시작하지 않는다 + if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.')); started = true; await _tool.runFfmpeg( buildMuxArgs(v, out, encode: encode, encoders: encoders, utf8Copies: copies), + tag: v, duration: v.info?.duration, onProgress: (x) { v.progress = x; @@ -1413,7 +1426,7 @@ class AppController extends ChangeNotifier { ? '${e.message}' : '$e'; // 187: 사용자가 취소한 것은 실패가 아니라 "취소됨" - final cancelled = gen != _cancelGen || _buildCancelled; + final cancelled = gen != _cancelGen || _buildCancelled || _cancelledVideos.contains(v); v ..status = cancelled ? JobStatus.cancelled : JobStatus.failed ..message = cancelled ? tr('취소됨') : msg; @@ -1425,6 +1438,7 @@ class AppController extends ChangeNotifier { } catch (_) {} } } finally { + _cancelledVideos.remove(v); for (final c in copies.values) { try { await services.storage.delete(c); diff --git a/lib/platform/android/ffmpeg_kit_media_tool.dart b/lib/platform/android/ffmpeg_kit_media_tool.dart index 19b50c9..0923a1a 100644 --- a/lib/platform/android/ffmpeg_kit_media_tool.dart +++ b/lib/platform/android/ffmpeg_kit_media_tool.dart @@ -59,7 +59,7 @@ class FfmpegKitMediaTool implements MediaTool { } @override - Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress}) async { + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { // 표준 출력으로 진행률을 쓰는 인수는 빼고 통계 콜백을 쓴다 final a = []; for (var i = 0; i < args.length; i++) { @@ -78,6 +78,7 @@ class FfmpegKitMediaTool implements MediaTool { (s) async { final id = s.getSessionId(); _running.remove(id); + _tags.remove(id); final rc = await s.getReturnCode(); if (_cancelled.remove(id) || ReturnCode.isCancel(rc)) { done.completeError(MediaToolException(tr('사용자가 취소했습니다.'))); @@ -104,7 +105,10 @@ class FfmpegKitMediaTool implements MediaTool { }, ); final id = session.getSessionId(); - if (id != null && !done.isCompleted) _running.add(id); + if (id != null && !done.isCompleted) { + _running.add(id); + if (tag != null) _tags[id] = tag; + } await done.future; onProgress?.call(1.0); } @@ -116,4 +120,15 @@ class FfmpegKitMediaTool implements MediaTool { unawaited(FFmpegKit.cancel(id)); } } + + final _tags = {}; + + @override + void cancelTagged(Object tag) { + for (final id in _running.toList()) { + if (_tags[id] != tag) continue; + _cancelled.add(id); + unawaited(FFmpegKit.cancel(id)); + } + } } diff --git a/lib/platform/windows/process_media_tool.dart b/lib/platform/windows/process_media_tool.dart index 06d5df7..9e150ba 100644 --- a/lib/platform/windows/process_media_tool.dart +++ b/lib/platform/windows/process_media_tool.dart @@ -108,6 +108,7 @@ class ProcessMediaTool implements MediaTool { List args, { Duration? duration, ProgressCallback? onProgress, + Object? tag, }) async { final Process proc; try { @@ -116,6 +117,7 @@ class ProcessMediaTool implements MediaTool { throw MediaToolException(trf('ffmpeg 를 실행할 수 없습니다: {0}', [e.message])); } _running.add(proc); + if (tag != null) _tags[proc] = tag; final totalUs = duration?.inMicroseconds ?? 0; final stdoutDone = proc.stdout @@ -142,6 +144,7 @@ class ProcessMediaTool implements MediaTool { final code = await proc.exitCode; await Future.wait([stdoutDone, stderrDone]); _running.remove(proc); + _tags.remove(proc); if (_killed.remove(proc)) throw MediaToolException(tr('사용자가 취소했습니다.')); if (code != 0) { @@ -159,6 +162,17 @@ class ProcessMediaTool implements MediaTool { proc.kill(); } } + + final _tags = {}; + + @override + void cancelTagged(Object tag) { + for (final proc in _running.toList()) { + if (_tags[proc] != tag) continue; + _killed.add(proc); + proc.kill(); + } + } } /// `ffmpeg -encoders` 출력 → 영상 인코더 이름 diff --git a/lib/services/media_tool.dart b/lib/services/media_tool.dart index 6dc638f..1c8ff27 100644 --- a/lib/services/media_tool.dart +++ b/lib/services/media_tool.dart @@ -24,12 +24,17 @@ abstract class MediaTool { Future> encoders(); /// FFmpeg 실행. [duration] 이 있으면 진행률을 계산해 [onProgress] 로 알린다. + /// [tag] (239): 이 실행만 골라 취소할 때 ([cancelTagged]) 쓰는 표 (예: 만드는 동영상) Future runFfmpeg( List args, { Duration? duration, ProgressCallback? onProgress, + Object? tag, }); /// 실행 중인 작업 취소 void cancel(); + + /// 239: [tag] 를 붙여 실행한 것만 취소 (동시에 도는 다른 것은 그대로) + void cancelTagged(Object tag); } diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index a887a5b..538ffd1 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -140,12 +140,40 @@ class _TopBar extends StatelessWidget { Future _cancelJobs(BuildContext context) async { final inBatch = c.batchWaiting; final waiting = c.pendingJobs.length + inBatch; + // 239: 창을 연 때 돌고 있던 것 - [지금 것만 취소] 는 이것만 (그사이 끝나고 다음 것이 시작해도 다음 것은 취소하지 않는다) + final targets = c.buildingMkv ? c.runningVideos : const []; if (waiting == 0) { + // 240: 여럿이 동시에 돌고 있으면 묻는다 (하나씩은 목록 줄의 ■) + if (targets.length > 1) { + final all = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + actionsOverflowDirection: VerticalDirection.down, + title: Text(tr('작업 취소')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('지금: {0}', [targets.map((v) => v.fileName).join(', ')])), + const SizedBox(height: 4), + Text(tr('하나씩 멈추려면 목록 줄의 ■ 를 누르세요.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, true), + child: Text(trf('모두 취소 ({0}개)', [targets.length])), + ), + ], + ), + ); + if (all == true) c.cancel(); + return; + } c.cancel(); return; } if (inBatch > 0) { - final now = c.runningVideoNames.join(', '); + final now = targets.map((v) => v.fileName).join(', '); final pick = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -169,7 +197,15 @@ class _TopBar extends StatelessWidget { ], ), ); - if (pick == 'current') c.cancelRunningVideos(); + if (pick == 'current') { + final gone = c.cancelVideos(targets); + // 239: 창을 보는 사이 이미 끝났으면 알리고 지금 상태로 다시 묻는다 + if (gone.isNotEmpty && context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(trf('{0} 는 이미 끝났습니다', [gone.map((v) => v.fileName).join(', ')])))); + if (c.busy) await _cancelJobs(context); + } + } if (pick == 'all') c.cancel(); return; } @@ -851,6 +887,14 @@ class _VideoTile extends StatelessWidget { onPressed: () => c.resetStatus(v), icon: const Icon(Icons.refresh, color: JjColors.textDim), ), + // 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로) + if (v.status == JobStatus.running && c.buildingMkv) + IconButton( + tooltip: tr('이 동영상만 취소'), + iconSize: 16, + onPressed: () => c.cancelVideos([v]), + icon: const Icon(Icons.stop, color: JjColors.danger), + ), IconButton( tooltip: tr('목록에서 제거'), iconSize: 16, diff --git a/test/batch_test.dart b/test/batch_test.dart index bce52ff..68732f4 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -213,7 +213,7 @@ void main() { } expect(v1.status, JobStatus.running); expect(c.batchWaiting, 2, reason: '한 묶음 안의 기다리는 동영상도 대기로 센다'); - c.cancelRunningVideos(); + expect(c.cancelVideos(c.runningVideos), isEmpty); await building; expect([v1.status, v2.status, v3.status], [JobStatus.cancelled, JobStatus.done, JobStatus.done], reason: '${v1.message} ${v2.message} ${v3.message}'); @@ -222,6 +222,44 @@ void main() { expect(c.logs.join('\n'), contains('성공 2 · 취소 1 · 실패 0 / 전체 3')); }); + test('239: 취소 창을 연 뒤 그 동영상이 끝나고 다음 것이 시작했으면 [지금 것만 취소] 는 다음 것을 취소하지 않는다', () async { + final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); + if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); + final dir = Directory.systemTemp.createTempSync('jj_batch239_'); + addTearDown(() => dir.deleteSync(recursive: true)); + // v1 은 짧게 (창을 보는 사이 끝남), v2 는 길게 + 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 == 1 ? '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]); + for (var i = 0; i < 400 && v1.status != JobStatus.running; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + final shown = c.runningVideos; // 창을 연 때: v1 + expect(shown, [v1]); + // 창을 보는 사이 v1 이 끝나고 v2 가 시작 + for (var i = 0; i < 800 && !(v2.status == JobStatus.running && v2.progress > 0.01); i++) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect([v1.status, v2.status], [JobStatus.done, JobStatus.running]); + expect(c.cancelVideos(shown), [v1], reason: '이미 끝남 - 알리고 다시 묻는다'); + await Future.delayed(const Duration(milliseconds: 500)); + expect(v2.status, JobStatus.running, reason: '창이 보여 준 것이 아닌 v2 는 취소하지 않음'); + // 다시 묻는 창에서 v2 를 취소 → v3 은 이어서 + expect(c.cancelVideos(c.runningVideos), isEmpty); + await building; + expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.cancelled, JobStatus.done], + reason: '${v1.message} ${v2.message} ${v3.message}'); + }); + testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 tester.view.devicePixelRatio = 1; diff --git a/test/queue_test.dart b/test/queue_test.dart index 0871c61..86d4531 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -64,7 +64,7 @@ class _Tool implements MediaTool { @override Future probe(String path) async => const MediaInfo(duration: Duration(seconds: 1)); @override - Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress}) async { + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { now++; if (now > peak) peak = now; await Future.delayed(const Duration(milliseconds: 30)); @@ -73,6 +73,8 @@ class _Tool implements MediaTool { @override void cancel() {} + @override + void cancelTagged(Object tag) {} } void main() { diff --git a/test/restore_access_test.dart b/test/restore_access_test.dart index 6351429..82a4cb2 100644 --- a/test/restore_access_test.dart +++ b/test/restore_access_test.dart @@ -33,9 +33,11 @@ class _Tool implements MediaTool { } @override - Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress}) async {} + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {} @override void cancel() {} + @override + void cancelTagged(Object tag) {} } void main() { diff --git a/test/ui_test.dart b/test/ui_test.dart index 6c6ee7b..db56ace 100644 --- a/test/ui_test.dart +++ b/test/ui_test.dart @@ -31,9 +31,11 @@ class _FakeTool implements MediaTool { ); @override Future runFfmpeg(List args, - {Duration? duration, ProgressCallback? onProgress}) async {} + {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {} @override void cancel() {} + @override + void cancelTagged(Object tag) {} } class _FakeStorage implements StorageService {