MKV 묶음 취소: 지금 것만 / 남은 것 모두 / 그대로 · 취소됨 상태 (186 · 187)

- 186 (사용자 편의, 기기 확인 지적): 묶음 실행 중 취소를 누르면 기다리는 항목이 있을 때 세 가지 중 고르기
- 187 (버그): 취소한 항목이 실패로 보이던 것 - JobStatus.cancelled, 회색으로 보이고 기록에 성공 · 취소 · 실패 / 전체

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-11 23:13:08 +09:00
parent c9c4f031b5
commit 27d50891d0
8 changed files with 137 additions and 13 deletions

View File

@ -570,6 +570,8 @@
"날짜": "Date", "날짜": "Date",
"남는 것": "What stays", "남는 것": "What stays",
"남은 {0}장": "{0} left", "남은 {0}장": "{0} left",
"남은 것 모두 취소 ({0}개)": "Cancel all remaining ({0})",
"남은 것: MKV 만들기 {0}개{1}": "Remaining: {0} MKV{1}",
"남은 공간 {0}": "{0} free", "남은 공간 {0}": "{0} free",
"남은 시간 {0}": "{0} left", "남은 시간 {0}": "{0} left",
"남은 약 {0}": "about {0} left", "남은 약 {0}": "about {0} left",
@ -1328,6 +1330,7 @@
"완료": "Done", "완료": "Done",
"완료 정리": "Clear completed", "완료 정리": "Clear completed",
"완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total", "완료: 성공 {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", "완료시 자동 동영상추가": "Auto-add videos when done",
"외부": "External", "외부": "External",
"외부 브라우저": "External browser", "외부 브라우저": "External browser",
@ -1745,6 +1748,8 @@
"즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)", "즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)",
"지금": "Current", "지금": "Current",
"지금 {0}%": "Now {0}%", "지금 {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?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
"지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password", "지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password",
"지금 맞추기": "Sync now", "지금 맞추기": "Sync now",
@ -1855,6 +1860,7 @@
"취소": "Cancel", "취소": "Cancel",
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)",
"취소됨": "Cancelled", "취소됨": "Cancelled",
"취소됨: {0}": "Cancelled: {0}",
"취소했습니다": "Cancelled", "취소했습니다": "Cancelled",
"취소했습니다.": "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).", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "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).",

View File

@ -570,6 +570,8 @@
"날짜": "日付", "날짜": "日付",
"남는 것": "残るもの", "남는 것": "残るもの",
"남은 {0}장": "残り {0} 枚", "남은 {0}장": "残り {0} 枚",
"남은 것 모두 취소 ({0}개)": "残りをすべて取り消し ({0} 件)",
"남은 것: MKV 만들기 {0}개{1}": "残り: MKV 作成 {0} 件{1}",
"남은 공간 {0}": "空き容量 {0}", "남은 공간 {0}": "空き容量 {0}",
"남은 시간 {0}": "残り時間 {0}", "남은 시간 {0}": "残り時間 {0}",
"남은 약 {0}": "残り約 {0}", "남은 약 {0}": "残り約 {0}",
@ -1328,6 +1330,7 @@
"완료": "完了", "완료": "完了",
"완료 정리": "完了を整理", "완료 정리": "完了を整理",
"완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}", "완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}",
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完了: 成功 {0} · 取消 {1} · 失敗 {2} / 全 {3}",
"완료시 자동 동영상추가": "完了時に動画を自動追加", "완료시 자동 동영상추가": "完了時に動画を自動追加",
"외부": "外部", "외부": "外部",
"외부 브라우저": "外部ブラウザー", "외부 브라우저": "外部ブラウザー",
@ -1745,6 +1748,8 @@
"즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)", "즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)",
"지금": "現在", "지금": "現在",
"지금 {0}%": "現在 {0}%", "지금 {0}%": "現在 {0}%",
"지금 것만 취소 (다음 것부터 이어서)": "今のものだけ取り消し (次から続ける)",
"지금 것만 취소: {0}": "今のものだけ取り消し: {0}",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
"지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード", "지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード",
"지금 맞추기": "今すぐ同期", "지금 맞추기": "今すぐ同期",
@ -1855,6 +1860,7 @@
"취소": "キャンセル", "취소": "キャンセル",
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)",
"취소됨": "キャンセル済み", "취소됨": "キャンセル済み",
"취소됨: {0}": "取り消し: {0}",
"취소했습니다": "キャンセルしました", "취소했습니다": "キャンセルしました",
"취소했습니다.": "キャンセルしました。", "취소했습니다.": "キャンセルしました。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。",

View File

@ -570,6 +570,8 @@
"날짜": "日期", "날짜": "日期",
"남는 것": "保留的内容", "남는 것": "保留的内容",
"남은 {0}장": "剩余 {0} 张", "남은 {0}장": "剩余 {0} 张",
"남은 것 모두 취소 ({0}개)": "取消全部剩余项({0} 个)",
"남은 것: MKV 만들기 {0}개{1}": "剩余:MKV 制作 {0} 个{1}",
"남은 공간 {0}": "剩余空间 {0}", "남은 공간 {0}": "剩余空间 {0}",
"남은 시간 {0}": "剩余时间 {0}", "남은 시간 {0}": "剩余时间 {0}",
"남은 약 {0}": "剩余约 {0}", "남은 약 {0}": "剩余约 {0}",
@ -1328,6 +1330,7 @@
"완료": "完成", "완료": "完成",
"완료 정리": "清理已完成", "완료 정리": "清理已完成",
"완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}", "완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}",
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完成:成功 {0} · 取消 {1} · 失败 {2} / 共 {3}",
"완료시 자동 동영상추가": "完成后自动添加视频", "완료시 자동 동영상추가": "完成后自动添加视频",
"외부": "外部", "외부": "外部",
"외부 브라우저": "外部浏览器", "외부 브라우저": "外部浏览器",
@ -1745,6 +1748,8 @@
"즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)", "즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)",
"지금": "当前", "지금": "当前",
"지금 {0}%": "当前 {0}%", "지금 {0}%": "当前 {0}%",
"지금 것만 취소 (다음 것부터 이어서)": "仅取消当前项(从下一个继续)",
"지금 것만 취소: {0}": "仅取消当前项:{0}",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
"지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)", "지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)",
"지금 맞추기": "立即同步", "지금 맞추기": "立即同步",
@ -1855,6 +1860,7 @@
"취소": "取消", "취소": "取消",
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)", "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)",
"취소됨": "已取消", "취소됨": "已取消",
"취소됨: {0}": "已取消:{0}",
"취소했습니다": "已取消", "취소했습니다": "已取消",
"취소했습니다.": "已取消。", "취소했습니다.": "已取消。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。",

View File

@ -1328,25 +1328,51 @@ class AppController extends ChangeNotifier {
if (queue.isEmpty) return; if (queue.isEmpty) return;
final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs; 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)])); if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)]));
var next = 0; _batchQueue = queue;
_batchNext = 0;
Future<void> worker() async { Future<void> worker() async {
while (!_buildCancelled && next < queue.length) { while (!_buildCancelled && _batchNext < queue.length) {
await _build(queue[next++]); await _build(queue[_batchNext++]);
} }
} }
await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]);
// 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음) // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다
final ok = queue.where((v) => v.status == JobStatus.done).length; int count(JobStatus s) => queue.where((v) => v.status == s).length;
_log(trf('완료: 성공 {0} / 전체 {1}', [ok, queue.length])); _log(trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}',
[count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length]));
} finally { } finally {
buildingMkv = false; buildingMkv = false;
_batchQueue = const [];
_batchNext = 0;
notifyListeners(); notifyListeners();
} }
} }
/// 186: 지금 MKV 만들기 묶음 (차례를 기다리는 동영상도 "대기" 로 센다)
List<VideoItem> _batchQueue = const [];
int _batchNext = 0;
/// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수
int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0;
/// 186: 지금 만드는 동영상 이름 (취소 창에 보일 것)
List<String> 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<void> _build(VideoItem v) async { Future<void> _build(VideoItem v) async {
final out = outputMkvPath(v.path); final out = outputMkvPath(v.path);
final gen = _cancelGen;
v v
..status = JobStatus.running ..status = JobStatus.running
..progress = 0 ..progress = 0
@ -1386,10 +1412,12 @@ class AppController extends ChangeNotifier {
: e is ArgumentError : e is ArgumentError
? '${e.message}' ? '${e.message}'
: '$e'; : '$e';
// 187: 사용자가 취소한 것은 실패가 아니라 "취소됨"
final cancelled = gen != _cancelGen || _buildCancelled;
v v
..status = JobStatus.failed ..status = cancelled ? JobStatus.cancelled : JobStatus.failed
..message = msg; ..message = cancelled ? tr('취소됨') : msg;
_log(trf('실패: {0}\n{1}', [v.fileName, msg])); _log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg]));
// 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다. // 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다.
if (started) { if (started) {
try { try {
@ -1418,6 +1446,7 @@ class AppController extends ChangeNotifier {
/// 45: 지금 작업만 멈추고 대기 중인 작업은 이어서 (다음 작업이 시작하면 취소 표시는 다시 꺼진다) /// 45: 지금 작업만 멈추고 대기 중인 작업은 이어서 (다음 작업이 시작하면 취소 표시는 다시 꺼진다)
void cancelCurrent() { void cancelCurrent() {
if (!busy) return; if (!busy) return;
_cancelGen++;
_log(trf('지금 작업 취소: {0}', [currentJob ?? ''])); _log(trf('지금 작업 취소: {0}', [currentJob ?? '']));
_recognizer?.cancel(); _recognizer?.cancel();
_buildCancelled = true; _buildCancelled = true;
@ -1427,6 +1456,7 @@ class AppController extends ChangeNotifier {
} }
void cancel() { void cancel() {
_cancelGen++;
_recognizer?.cancel(); _recognizer?.cancel();
if (pendingJobs.isNotEmpty) _log(trf('대기 중인 작업 {0}개 취소', [pendingJobs.length])); if (pendingJobs.isNotEmpty) _log(trf('대기 중인 작업 {0}개 취소', [pendingJobs.length]));
pendingJobs.clear(); pendingJobs.clear();

View File

@ -84,7 +84,8 @@ class SubtitleEntry {
: trf('트랙 #{0} ({1}){2}', [streamIndex, codec, title != null ? ' - $title' : '']); : 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 { class VideoItem {
final String path; final String path;

View File

@ -135,12 +135,44 @@ class _TopBar extends StatelessWidget {
/// 고른 동영상이 없을 때: 목록 전체를 만들지 묻는다 /// 고른 동영상이 없을 때: 목록 전체를 만들지 묻는다
/// 보고 있는 동영상의 결과 폴더 (jj_mkv) 를 탐색기 · 파일 앱으로. 아직 없으면 알린다. /// 보고 있는 동영상의 결과 폴더 (jj_mkv) 를 탐색기 · 파일 앱으로. 아직 없으면 알린다.
/// 45: 대기 중인 작업이 있으면 지금 작업만 / 모두 고르게 한다 (없으면 지금 작업을 바로 멈춤 - 예전처럼) /// 45: 대기 중인 작업이 있으면 지금 작업만 / 모두 고르게 한다 (없으면 지금 작업을 바로 멈춤 - 예전처럼)
/// 186: 한 묶음 안의 차례를 기다리는 동영상 (MKV 만들기 3개 중 v2 · v3) 도 대기로 센다 - 남은 것이 있으면
/// "지금 것만 취소 · 남은 것 모두 취소 · 그대로 두기" 를 묻는다 (예전에는 묻지 않고 모두 멈췄다)
Future<void> _cancelJobs(BuildContext context) async { Future<void> _cancelJobs(BuildContext context) async {
final waiting = c.pendingJobs.length; final inBatch = c.batchWaiting;
final waiting = c.pendingJobs.length + inBatch;
if (waiting == 0) { if (waiting == 0) {
c.cancel(); c.cancel();
return; return;
} }
if (inBatch > 0) {
final now = c.runningVideoNames.join(', ');
final pick = await showDialog<String>(
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<String>( final pick = await showDialog<String>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -798,7 +830,11 @@ class _VideoTile extends StatelessWidget {
Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim)) Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim))
else else
Text( Text(
v.status == JobStatus.failed ? tr('실패') : trf('자막 {0}개', [subs]), v.status == JobStatus.failed
? tr('실패')
: v.status == JobStatus.cancelled
? tr('취소됨')
: trf('자막 {0}개', [subs]),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: v.status == JobStatus.failed 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( IconButton(
tooltip: tr('다시 만들기 대기로'), tooltip: tr('다시 만들기 대기로'),
iconSize: 16, iconSize: 16,
@ -842,6 +878,9 @@ class _StatusIcon extends StatelessWidget {
const Icon(Icons.check_circle, size: 20, color: JjColors.success), const Icon(Icons.check_circle, size: 20, color: JjColors.success),
JobStatus.failed => JobStatus.failed =>
const Icon(Icons.error, size: 20, color: JjColors.danger), const Icon(Icons.error, size: 20, color: JjColors.danger),
// 187: 취소는 실패가 아니라 회색
JobStatus.cancelled =>
const Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim),
}; };
} }

View File

@ -261,6 +261,7 @@ class _VideoRow extends StatelessWidget {
JobStatus.running => (Icons.autorenew, JjColors.accent), JobStatus.running => (Icons.autorenew, JjColors.accent),
JobStatus.done => (Icons.check_circle, JjColors.success), JobStatus.done => (Icons.check_circle, JjColors.success),
JobStatus.failed => (Icons.error, JjColors.danger), JobStatus.failed => (Icons.error, JjColors.danger),
JobStatus.cancelled => (Icons.cancel_outlined, JjColors.textDim), // 187
}; };
final sub = v.status == JobStatus.running final sub = v.status == JobStatus.running
? '${v.phase ?? ''} ${(v.progress * 100).round()}%' ? '${v.phase ?? ''} ${(v.progress * 100).round()}%'
@ -268,6 +269,7 @@ class _VideoRow extends StatelessWidget {
switch (v.status) { switch (v.status) {
JobStatus.done => tr('MKV 완성'), JobStatus.done => tr('MKV 완성'),
JobStatus.failed => trf('실패: {0}', [v.message ?? '']), JobStatus.failed => trf('실패: {0}', [v.message ?? '']),
JobStatus.cancelled => tr('취소됨'),
_ => trf('자막 {0}개', [v.subtitles.where((s) => s.enabled).length]), _ => trf('자막 {0}개', [v.subtitles.where((s) => s.enabled).length]),
}; };
return Padding( return Padding(

View File

@ -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/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.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/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart'; import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/core/srt.dart'; import 'package:jj_mkvmaker/core/srt.dart';
@ -188,6 +189,39 @@ void main() {
expect(one.status, JobStatus.done); 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<void>.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 { testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;