WIP 262 MKV 끝나는 순간 줄 ■: 같은 자리에 오는 ⟳ 는 묶음이 끝날 때까지 안 눌림, 집계는 끝났을 때의 결과로 · 시작 못 한 것만 취소됨 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
backup_묶음6_d46e089
RyuJongJin 2026-10-12 08:58:39 +09:00
parent 21d337d33c
commit e702864e5b
6 changed files with 131 additions and 7 deletions

View File

@ -781,6 +781,7 @@
"다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "When you go to another screen or the app goes to the background, the Tor screen's pages and connections are cleared right away. Tor itself keeps running for this long so you can use it again immediately.",
"다른 화면으로 가면 페이지 소리 멈춤": "Pause page sound when leaving the browser",
"다시 만들기 대기로": "Back to waiting for remake",
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "Queue again to rebuild (after the current batch finishes)",
"다시 받기": "Download again",
"다시 비교": "Compare again",
"다시 설치": "Reinstall",

View File

@ -781,6 +781,7 @@
"다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "別の画面に移るかアプリがバックグラウンドに回ると、Tor 画面のページ · 接続はすぐに片付けます。Tor 自体はこの時間だけ動かしておき、戻ったらすぐ使えます。",
"다른 화면으로 가면 페이지 소리 멈춤": "他の画面に移るとページの音を止める",
"다시 만들기 대기로": "作り直し待ちに戻す",
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "再作成待ちに戻す (今のまとめ作業が終わった後)",
"다시 받기": "再ダウンロード",
"다시 비교": "もう一度比較",
"다시 설치": "再インストール",

View File

@ -781,6 +781,7 @@
"다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "切换到其他页面或应用转到后台时,会立即清理 Tor 页面的网页和连接。Tor 本身会在这段时间内保持运行,返回后可立即使用。",
"다른 화면으로 가면 페이지 소리 멈춤": "离开浏览器时暂停网页声音",
"다시 만들기 대기로": "改回等待重新制作",
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "重新排队制作 (当前批次结束后)",
"다시 받기": "重新下载",
"다시 비교": "重新比较",
"다시 설치": "重新安装",

View File

@ -1383,24 +1383,32 @@ class AppController extends ChangeNotifier {
..message = null;
}
final skipped = <VideoItem>{};
// 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게)
final results = <VideoItem, JobStatus>{};
Future<void> worker() async {
while (!_buildCancelled && _batchNext < queue.length) {
final v = queue[_batchNext++];
if (!await _build(v, existing)) skipped.add(v);
if (await _build(v, existing)) {
results[v] = v.status;
} else {
skipped.add(v);
}
}
}
await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]);
// P0-2: [남은 것 모두 취소] 로 시작하지 못한 것은 "취소됨"
// P0-2: [남은 것 모두 취소] 로 시작하지 못한 것만 "취소됨".
// 262: 상태가 "대기" 인 것으로 고르면, 끝난 뒤 다시 대기로 돌린 것까지 "취소됨" 이 되었다
for (final v in queue) {
if (v.status == JobStatus.ready && !skipped.contains(v)) {
if (!results.containsKey(v) && !skipped.contains(v)) {
v
..status = JobStatus.cancelled
..message = tr('취소됨');
results[v] = JobStatus.cancelled;
}
}
// 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다
int count(JobStatus s) => queue.where((v) => v.status == s && !skipped.contains(v)).length;
int count(JobStatus s) => results.values.where((x) => x == s).length;
_log(skipped.isEmpty
? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}',
[count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length])
@ -1422,6 +1430,9 @@ class AppController extends ChangeNotifier {
int get batchTotal => buildingMkv ? _batchQueue.length : 0;
int get batchStarted => buildingMkv ? _batchNext.clamp(0, _batchQueue.length) : 0;
/// 262: [v] 가 지금 만드는 묶음에 들어 있는지
bool inCurrentBatch(VideoItem v) => buildingMkv && _batchQueue.contains(v);
/// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수
int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0;
@ -1587,7 +1598,8 @@ class AppController extends ChangeNotifier {
/// 완료·실패 상태를 초기화해 다시 만들 수 있게 한다.
void resetStatus(VideoItem v) {
if (v.status == JobStatus.running) return;
// 262: 지금 묶음의 것은 묶음이 끝난 뒤에 (끝나는 순간 ■ 자리에 ⟳ 가 나타나 ■ 를 누르려던 것이 ⟳ 로 들어갔다)
if (v.status == JobStatus.running || inCurrentBatch(v)) return;
v
..status = JobStatus.ready
..progress = 0

View File

@ -934,10 +934,11 @@ class _VideoTile extends StatelessWidget {
),
),
if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled)
// 262: 끝나는 순간 ■ 와 같은 자리에 나타난다 - 묶음이 끝날 때까지는 눌리지 않게 (자리는 그대로 두어 ✕ 가 밀려오지 않게)
IconButton(
tooltip: tr('다시 만들기 대기로'),
tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'),
iconSize: 16,
onPressed: () => c.resetStatus(v),
onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v),
icon: const Icon(Icons.refresh, color: JjColors.textDim),
),
// 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로)

View File

@ -97,6 +97,20 @@ class _Backend implements DownloadBackend {
Future<void> shutdown() async {}
}
/// 262: [_HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다
class _EndTool extends _HoldTool {
final args = <Object?, List<String>>{};
@override
Future<void> runFfmpeg(List<String> a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
args[tag] = a;
await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag);
File(a.last).writeAsStringSync('NEW');
}
/// [tag] 의 FFmpeg 를 성공으로 끝낸다
void finish(Object tag) => runs[tag]!.complete();
}
AppController _plain() =>
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
@ -704,6 +718,100 @@ void main() {
d.dispose();
});
test('262: 동영상이 끝나는 바로 그때 줄 ■ (지금 것만 취소) 가 와도 성공 그대로 - 줄 · 집계 · 파일', () async {
final tool = _EndTool();
final dir = Directory.systemTemp.createTempSync('jj_batch262_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['batch1', 'batch2', 'batch3']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init();
c.settings
..maxParallelJobs = 1
..mkvExisting = 'overwrite';
await c.addVideos(files);
final [v1, v2, v3] = c.videos;
File(outputMkvPath(v1.path))
..createSync(recursive: true)
..writeAsStringSync('OLD');
final building = c.buildVideos([v1, v2, v3]);
Future<void> until(bool Function() ok) async {
for (var i = 0; i < 300 && !ok(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 5));
}
}
await until(() => tool.runs.containsKey(v1));
// FFmpeg 끝 (성공) 바로 뒤 · 결과 이름으로 바꾸기 전에 ■
tool.finish(v1);
expect(v1.status, JobStatus.running);
c.cancelVideos([v1]);
// 끝난 바로 뒤 같은 자리의 ⟳ 를 눌러도 (손가락이 ■ 를 누르려던 것) 지금 묶음에서는 바뀌지 않는다
await until(() => v1.status == JobStatus.done);
c.resetStatus(v1);
expect(v1.status, JobStatus.done);
for (final v in [v2, v3]) {
await until(() => tool.runs.containsKey(v));
tool.finish(v);
}
await building;
expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.done, JobStatus.done], reason: '${v1.message}');
expect(File(outputMkvPath(v1.path)).readAsStringSync(), 'NEW');
final log = c.logs.join('\n');
expect(log, contains('성공: batch1.mkv'));
expect(log, contains('성공 3 · 취소 0 · 실패 0 / 전체 3'));
// 묶음이 끝나면 ⟳ 로 다시 대기로 돌릴 수 있다
c.resetStatus(v1);
expect(v1.status, JobStatus.ready);
});
testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = _EndTool();
final dir = Directory.systemTemp.createTempSync('jj_batch262w_');
addTearDown(() => dir.deleteSync(recursive: true));
final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path];
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
c.settings.maxParallelJobs = 1;
await c.addVideos(files);
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
final [a, b] = c.videos;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소'));
// a 가 끝나고 b 가 시작
await tester.runAsync(() async {
tool.finish(a);
for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
await tester.pump();
expect(a.status, JobStatus.done);
// ■ 를 누르려던 자리를 누른다
await tester.tapAt(stopAt);
await tester.pump();
expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음');
expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음');
await tester.runAsync(() async {
tool.finish(b);
await building;
});
await tester.pump();
expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2'));
// 누른 자리의 풍선 도움말 타이머 정리
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 2));
});
testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;