JJ_MKVMaker/lib/app/controller/video_list.dart

226 lines
8.1 KiB
Dart

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<void> 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<VideoItem> items = [];
VideoItem? selected;
/// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상)
final Set<VideoItem> checked = {};
/// 마지막으로 누른 정렬 ('name' · 'date') 과 방향. 같은 것을 다시 누르면 반대로.
String? sortedBy;
bool sortAscending = true;
/// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다
Future<void> add(List<String> 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<int> 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<void> follow(List<String> 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 = <VideoItem, DateTime>{};
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<VideoItem> 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<String>)> move(List<VideoItem> targets, String dest) async {
if (dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)')]);
var moved = 0;
final errors = <String>[];
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);
}
}