import 'dart:io'; import 'package:path/path.dart' as p; import '../../core/models.dart'; import '../../core/output_paths.dart'; import '../../core/playlist.dart'; import '../../l10n/tr.dart'; import 'video_files.dart'; /// 편집 목록 (동영상 · 보는 것 · 체크한 것). 약속: 작업 중인 동영상은 빼거나 옮기지 않는다. 목록이 바뀌면 [onChanged], /// 저장할 만큼 바뀌면 [onSave] (목록 파일 - [VideoListShare]). 분석 ([load]) 과 기록 ([log]) 은 부르는 쪽이 준다. class VideoList { final void Function() onChanged; final void Function() onSave; final Future Function(VideoItem v) load; final void Function(String msg) log; VideoList({required this.onChanged, required this.onSave, required this.load, required this.log}); final List items = []; VideoItem? selected; /// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상) final Set checked = {}; /// 마지막으로 누른 정렬 ('name' · 'date') 과 방향. 같은 것을 다시 누르면 반대로. String? sortedBy; bool sortAscending = true; /// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다 Future add(List paths, {bool allowOutputFolder = false}) async { for (final path in paths) { if (items.any((v) => p.equals(v.path, path))) continue; // jj_mkv 출력 폴더 안의 파일은 제외 (폴더째 넣을 때 만든 결과물이 다시 들어가지 않게) if (!allowOutputFolder && p.basename(p.dirname(path)) == outputFolderName) { log(trf('건너뜀 (출력 폴더의 파일): {0}', [p.basename(path)])); continue; } final v = VideoItem(path); items.add(v); selected ??= v; onChanged(); await load(v); } onSave(); } /// 116: 분석에 실패했던 동영상을 다시 분석한다 (권한을 허용하고 돌아왔을 때). 다시 분석한 수 Future reanalyzeFailed() async { var n = 0; for (final v in [...items]) { if (v.info != null || v.message == null || v.status == JobStatus.running) continue; if (!items.contains(v)) continue; v.message = null; v.subtitles.removeWhere((s) => s.kind == SubtitleKind.embedded); onChanged(); await load(v); n++; } if (n > 0) log(trf('분석에 실패했던 동영상 {0}개를 다시 분석했습니다', [n])); return n; } /// 다른 창이 바꾼 목록 [want] 에 맞춘다 (작업 중인 동영상은 지우지 않는다, 없는 파일은 넣지 않는다) Future follow(List want) async { bool inWant(VideoItem v) => want.any((w) => p.equals(w, v.path)); items.removeWhere((v) => !inWant(v) && v.status != JobStatus.running); if (selected != null && !items.contains(selected)) selected = items.firstOrNull; onChanged(); await add([for (final w in want) if (File(w).existsSync()) w], allowOutputFolder: true); } /// 파일 이름 ('name') 또는 파일 날짜 ('date') 순으로 정렬 void sort(String by) { sortAscending = sortedBy == by ? !sortAscending : true; sortedBy = by; final dates = {}; if (by == 'date') { for (final v in items) { try { dates[v] = File(v.path).lastModifiedSync(); } catch (_) { dates[v] = DateTime.fromMillisecondsSinceEpoch(0); } } } items.sort((a, b) { var d = by == 'date' ? dates[a]!.compareTo(dates[b]!) : 0; if (d == 0) d = naturalCompare(a.fileName.toLowerCase(), b.fileName.toLowerCase()); return sortAscending ? d : -d; }); onSave(); onChanged(); } void select(VideoItem v) { selected = v; onChanged(); } void toggleChecked(VideoItem v) { if (!checked.remove(v)) checked.add(v); onChanged(); } /// 모두 체크되어 있으면 모두 해제, 아니면 모두 체크 void toggleAllChecked() { if (checked.length == items.length) { checked.clear(); } else { checked ..clear() ..addAll(items); } onChanged(); } /// 일괄 작업 대상: 체크한 동영상 (목록 순서대로). 체크한 것이 없으면 지금 보고 있는 한 개. List get batchTargets { checked.retainAll(items); if (checked.isNotEmpty) return items.where(checked.contains).toList(); return [?selected]; } /// 체크한 동영상을 목록에서 제거 (작업 중인 것은 남긴다) void removeChecked() { final gone = checked.where((v) => v.status != JobStatus.running).toList(); items.removeWhere(gone.contains); checked.removeAll(gone); if (selected != null && !items.contains(selected)) selected = items.firstOrNull; _changed(); } void remove(VideoItem v) { if (v.status == JobStatus.running) return; items.remove(v); checked.remove(v); if (selected == v) selected = items.isEmpty ? null : items.first; _changed(); } void clear() { items.clear(); checked.clear(); selected = null; _changed(); } void _changed() { onSave(); onChanged(); } /// 동영상을 [dest] 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 과 /// 만든 결과물 (jj_mkv 의 a.mkv · a_AI.srt · a_ko.srt …) 도 함께 (결과물은 이동 폴더의 jj_mkv 로). /// 옮긴 동영상은 목록에서 뺀다. 반환: (옮긴 수, 못 옮긴 이유들) Future<(int, List)> move(List targets, String dest) async { if (dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)')]); var moved = 0; final errors = []; try { await Directory(dest).create(recursive: true); } catch (e) { return (0, [trf('이동할 폴더를 만들 수 없습니다: {0} ({1})', [dest, e])]); } for (final v in List.of(targets)) { if (v.status == JobStatus.running) { errors.add(trf('{0}: 작업 중이라 옮기지 않았습니다', [v.fileName])); continue; } if (p.equals(v.directory, dest)) { errors.add(trf('{0}: 이미 이동 폴더에 있습니다', [v.fileName])); continue; } // 옮기기 시작하면 바로 목록에서 뺀다 (다른 드라이브 · NAS 로는 복사라 큰 동영상은 몇 초 넘게 남아 보였다). // 옮기지 못하면 원래 자리 · 표시 · 선택으로 되돌린다. final at = items.indexOf(v); final wasChecked = checked.contains(v); final wasSelected = selected == v; items.remove(v); checked.remove(v); if (wasSelected) selected = items.elementAtOrNull(at) ?? items.lastOrNull; onChanged(); try { final side = { for (final s in v.subtitles) if (s.path != null && p.equals(p.dirname(s.path!), v.directory) && p.basename(s.path!).startsWith('${v.baseName}.') && File(s.path!).existsSync()) s.path!, }; final to = await VideoFiles.moveFileInto(v.path, dest); for (final s in side) { try { await VideoFiles.moveFileInto(s, dest); } catch (e) { log(trf('자막 이동 실패: {0} ({1})', [p.basename(s), e])); } } final results = await VideoFiles.moveResults(v.path, to, log: log); log(trf('이동: {0} → {1}{2}', [ v.fileName, to, [ if (side.isNotEmpty) trf(' (자막 {0}개 함께)', [side.length]), if (results > 0) trf(' (결과물 {0}개 함께)', [results]), ].join(), ])); moved++; } catch (e) { errors.add('${v.fileName}: $e'); if (File(v.path).existsSync() && !items.contains(v)) { items.insert(at.clamp(0, items.length), v); if (wasChecked) checked.add(v); if (wasSelected) selected = v; onChanged(); } } } if (selected != null && !items.contains(selected)) selected = items.firstOrNull; _changed(); return (moved, errors); } }