510 lines
24 KiB
Dart
510 lines
24 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../core/encode_options.dart';
|
|
import '../core/languages.dart';
|
|
import '../core/mkv_command_builder.dart' show bitmapSubtitleCodecs;
|
|
import '../core/models.dart';
|
|
import '../core/srt.dart';
|
|
import '../core/subtitle_detector.dart';
|
|
import '../core/subtitle_search.dart';
|
|
import '../services/subtitle_provider.dart';
|
|
import '../core/text_codec.dart';
|
|
import '../services/media_tool.dart';
|
|
import '../services/platform_services.dart';
|
|
import '../services/system_usage.dart';
|
|
import 'settings.dart';
|
|
import 'cleanup.dart' show removeUpdateLeftovers;
|
|
import 'master_lock.dart' show MasterLock;
|
|
import '../l10n/tr.dart';
|
|
import 'controller/ai_options.dart';
|
|
import 'controller/ai_subtitle_runner.dart';
|
|
import 'controller/app_log.dart';
|
|
import 'controller/encode_choice.dart';
|
|
import 'controller/job_queue.dart';
|
|
import 'controller/mkv_batch.dart';
|
|
import 'controller/subtitle_download.dart';
|
|
import 'controller/subtitle_files.dart';
|
|
import 'controller/app_runtime.dart';
|
|
import 'controller/playback_prep.dart';
|
|
import 'controller/video_list.dart';
|
|
import 'controller/video_list_share.dart';
|
|
import 'controller/video_probe.dart';
|
|
|
|
export 'controller/ai_options.dart';
|
|
|
|
/// 화면 상태와 작업 흐름 (플랫폼 무관). 일은 controller/ 의 모듈이 하고, 여기는 목록 · 선택 · 설정과 모듈을 잇기만 한다
|
|
class AppController extends ChangeNotifier {
|
|
final PlatformServices services;
|
|
|
|
/// 설정 저장소 (null 이면 저장하지 않음 - 테스트용)
|
|
final SettingsStore? settingsStore;
|
|
AppSettings settings = AppSettings();
|
|
|
|
AppController(this.services, {this.settingsStore});
|
|
|
|
/// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용
|
|
Future<bool> Function()? confirmQuit;
|
|
|
|
/// 위쪽 막대의 CPU · MEM 표시 (2초마다, 보는 화면이 있을 때만 잰다). 지원하지 않으면 null.
|
|
late final UsageMonitor? usage = services.usage == null ? null : UsageMonitor(services.usage!, diskPath: _disk.of);
|
|
|
|
/// 다운로드 폴더가 있는 드라이브 (설정이 바뀔 때만 다시 알아낸다)
|
|
late final _disk = DownloadDisk(() => settings);
|
|
|
|
// ───────── 모듈 ─────────
|
|
|
|
final _logBook = AppLog();
|
|
late final _jobs = JobQueue(onChanged: notifyListeners, log: _log);
|
|
late final _files = SubtitleFiles(services.storage, _tool);
|
|
late final _probe = VideoProbe(storage: services.storage, tool: _tool, files: _files, log: _log, fileAccess: () => fileAccess);
|
|
late final _ai = AiSubtitleRunner(services: services, files: _files, onChanged: notifyListeners, log: _log);
|
|
late final _mkv = MkvBatch(storage: services.storage, tool: _tool, files: _files, onChanged: notifyListeners, log: _log);
|
|
late final _download = SubtitleDownload(services.storage, log: _log);
|
|
final _encode = EncodeChoice();
|
|
late final VideoListShare _share = VideoListShare(paths: () => [for (final v in videos) v.path], apply: (want) => _list.follow(want));
|
|
late final VideoList _list = VideoList(onChanged: notifyListeners, onSave: () => _share.save(), load: _loadVideo, log: _log);
|
|
late final _play = PlaybackPrep(storage: services.storage, shell: services.shell, log: _log);
|
|
|
|
// ───────── 목록 정렬 ─────────
|
|
|
|
/// 마지막으로 누른 정렬 ('name' · 'date') 과 방향 ([VideoList])
|
|
String? get sortedBy => _list.sortedBy;
|
|
bool get sortAscending => _list.sortAscending;
|
|
|
|
/// 동영상 목록을 파일 이름 ('name') 또는 파일 날짜 ('date') 순으로 정렬. 같은 것을 다시 누르면 반대로.
|
|
void sortVideos(String by) => _list.sort(by);
|
|
|
|
/// 설정 변경 후 저장·적용
|
|
Future<void> updateSettings(void Function(AppSettings s) change) async {
|
|
change(settings);
|
|
_applySettings();
|
|
notifyListeners();
|
|
await settingsStore?.save(settings);
|
|
}
|
|
|
|
/// 안전 저장소에 저장하지 못한 비밀번호를 다시 저장 (화면 위 알림의 [다시 시도] - 40-1 · 40-3)
|
|
Future<bool> retrySecrets() async {
|
|
final store = settingsStore;
|
|
if (store == null) return true;
|
|
final ok = await store.retrySecrets(settings);
|
|
// M0: 저장소가 다시 읽히면 마스터 잠금도 다시 읽는다 (알 수 없음 → 평소대로)
|
|
if (ok) await MasterLock.instance?.reload();
|
|
_applySettings();
|
|
notifyListeners();
|
|
return ok;
|
|
}
|
|
|
|
void _applySettings() => applyRuntimeSettings(settings);
|
|
|
|
void _saveSettings() => settingsStore?.save(settings);
|
|
|
|
MediaTool get _tool => services.mediaTool;
|
|
|
|
/// 편집 목록 · 보고 있는 동영상 ([VideoList])
|
|
List<VideoItem> get videos => _list.items;
|
|
List<String> get logs => _logBook.lines;
|
|
VideoItem? get selected => _list.selected;
|
|
set selected(VideoItem? v) => _list.selected = v;
|
|
String? ffmpegVersion;
|
|
|
|
/// 무거운 작업 중인지 · 실행 중인 작업 이름 · 대기 중인 작업 이름 ([JobQueue])
|
|
bool get busy => _jobs.busy;
|
|
set busy(bool v) => _jobs.busy = v;
|
|
String? get currentJob => _jobs.current;
|
|
set currentJob(String? v) => _jobs.current = v;
|
|
List<String> get pendingJobs => _jobs.pending;
|
|
|
|
/// 화면 크기 · 코덱 · 화질 (모든 동영상에 적용 - [EncodeChoice])
|
|
EncodeSettings get encode => _encode.value;
|
|
set encode(EncodeSettings e) => _encode.value = e;
|
|
|
|
/// 코덱이 저절로 바뀌었을 때 화면에 짧게 알릴 글 (보여 주면 비움)
|
|
String? get encodeNotice => _encode.notice;
|
|
set encodeNotice(String? v) => _encode.notice = v;
|
|
|
|
/// FFmpeg 가 지원하는 영상 인코더
|
|
Set<String> encoders = {};
|
|
|
|
Future<void> init() async {
|
|
if (settingsStore != null) {
|
|
settings = await settingsStore!.load();
|
|
final problem = settingsStore!.problem;
|
|
if (problem != null) {
|
|
_log(problem.restoredFromBackup
|
|
? trf('설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}', [problem.brokenCopy ?? '-', problem.error])
|
|
: trf('설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}', [problem.brokenCopy ?? '-', problem.error]));
|
|
}
|
|
// 앱 안 브라우저의 로그인 · 쿠키 폴더 (yt-dlp 가 같은 쿠키를 읽음)
|
|
try {
|
|
settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview');
|
|
} catch (_) {}
|
|
encode = settings.mkv.encode;
|
|
aiOptions = AiOptions.fromSaved(source: settings.mkv.aiSource, targets: settings.mkv.aiTargets, whisper: settings.mkv.aiWhisper);
|
|
_applySettings();
|
|
}
|
|
if (Platform.isWindows) {
|
|
final gone = removeUpdateLeftovers(Directory.systemTemp.path);
|
|
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')]));
|
|
}
|
|
// 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를
|
|
// 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음)
|
|
unawaited(cleanLeftovers(services.storage.tempDirectory, _log));
|
|
ffmpegVersion = await _tool.version();
|
|
if (ffmpegVersion == null) {
|
|
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));
|
|
return;
|
|
}
|
|
encoders = await _tool.encoders();
|
|
// 이 기기에 없는 코덱은 코덱 고르기 목록에 "(사용 불가)" 로 보인다. 켤 때마다 경고처럼 남기지 않는다 (고장으로 보였음)
|
|
_log(trf('FFmpeg 준비됨: {0}' '{1}', [ffmpegVersion, '']));
|
|
}
|
|
|
|
bool isCodecAvailable(VideoCodecChoice c) =>
|
|
c == VideoCodecChoice.copy || c.pickEncoder(encoders) != null;
|
|
|
|
void setCodec(VideoCodecChoice c) => _encodeChanged(() => _encode.setCodec(c));
|
|
|
|
/// 화면 비율 · 회전 · 색 보정 바꾸기 (재인코딩이 필요하면 기본 H.264 로)
|
|
void setAdjust(EncodeSettings e) => _encodeChanged(() => _encode.setAdjust(e));
|
|
void setResolution(ResolutionChoice r) => _encodeChanged(() => _encode.setResolution(r));
|
|
void setQuality(QualityChoice q) => _encodeChanged(() => _encode.setQuality(q));
|
|
|
|
void _encodeChanged(void Function() change) {
|
|
change();
|
|
settings.mkv.encode = encode;
|
|
_saveSettings();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 원본보다 큰 크기로 인코딩하게 되는 동영상 수
|
|
int get upscaleCount => videos
|
|
.where((v) => isUpscale(encode.resolution, v.info?.ofType('video').firstOrNull))
|
|
.length;
|
|
|
|
// ───────── 동영상 ─────────
|
|
|
|
Future<void> pickVideos() async {
|
|
final paths = await services.storage.pickVideos();
|
|
// 33: 사용자가 직접 고른 파일은 jj_mkv 폴더 안에 있어도 넣는다
|
|
await addVideos(paths, allowOutputFolder: true);
|
|
}
|
|
|
|
/// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다
|
|
Future<void> addVideos(List<String> paths, {bool allowOutputFolder = false}) => _list.add(paths, allowOutputFolder: allowOutputFolder);
|
|
|
|
/// 동영상 목록 저장 · 창끼리 공유 시작 ([file] 을 먼저 불러온다 - [VideoListShare])
|
|
Future<void> shareVideoList(String file) => _share.start(file);
|
|
void stopVideoListShare() => _share.stop();
|
|
|
|
/// 파일이 바뀌었으면 (다른 창에서 넣거나 지움) 이 창의 목록을 맞춘다
|
|
Future<void> syncVideoList() => _share.sync();
|
|
|
|
/// 116 (Android): 저장소 전체 접근 권한이 있는지 (null = 묻지 않음 - Windows)
|
|
Future<bool> Function()? fileAccess;
|
|
|
|
/// 116: 분석에 실패했던 동영상을 다시 분석한다 (권한을 허용하고 돌아왔을 때). 다시 분석한 수
|
|
Future<int> reanalyzeFailed() => _list.reanalyzeFailed();
|
|
|
|
Future<void> _loadVideo(VideoItem v) async {
|
|
await _probe.load(v);
|
|
notifyListeners();
|
|
}
|
|
|
|
void select(VideoItem v) => _list.select(v);
|
|
|
|
/// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상)
|
|
Set<VideoItem> get checked => _list.checked;
|
|
void toggleChecked(VideoItem v) => _list.toggleChecked(v);
|
|
|
|
/// 모두 체크되어 있으면 모두 해제, 아니면 모두 체크
|
|
void toggleAllChecked() => _list.toggleAllChecked();
|
|
|
|
/// 일괄 작업 대상: 체크한 동영상 (목록 순서대로). 체크한 것이 없으면 지금 보고 있는 한 개.
|
|
List<VideoItem> get batchTargets => _list.batchTargets;
|
|
|
|
/// 체크한 동영상을 목록에서 제거 (작업 중인 것은 남긴다)
|
|
void removeChecked() => _list.removeChecked();
|
|
|
|
/// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤)
|
|
Future<void> aiThenBuild(List<VideoItem> targets, AiOptions opts, {String? onExisting}) async {
|
|
await generateAiSubtitles(targets, opts);
|
|
if (_ai.cancelled) return;
|
|
await buildVideos(targets, onExisting: onExisting);
|
|
}
|
|
|
|
/// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수.
|
|
Future<int> addDownloaded(List<String> files) async {
|
|
final before = videos.length;
|
|
await addVideos(files);
|
|
final n = videos.length - before;
|
|
for (final v in videos.skip(before)) {
|
|
_log(trf('받은 동영상을 편집 목록에 추가: {0}', [v.fileName]));
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/// 동영상을 [dest] (환경 설정의 이동 버튼 폴더) 로 옮긴다 - 옆의 같은 이름 자막 · 만든 결과물도 함께 ([VideoList.move]).
|
|
/// 작업 중인 것은 옮기지 않는다. 반환: (옮긴 수, 못 옮긴 이유들)
|
|
Future<(int, List<String>)> moveVideos(List<VideoItem> targets, String dest) => _list.move(targets, dest);
|
|
|
|
void removeVideo(VideoItem v) => _list.remove(v);
|
|
|
|
void clearVideos() {
|
|
if (!busy) _list.clear();
|
|
}
|
|
|
|
// ───────── 자막 ─────────
|
|
|
|
Future<void> pickSubtitlesFor(VideoItem v) async {
|
|
final paths = await services.storage.pickSubtitles(initialDirectory: v.directory);
|
|
for (final path in paths) {
|
|
if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, path))) continue;
|
|
final d = findSiblingSubtitles(v.path, [path]);
|
|
v.subtitles.add(await _files.external(path, d.isEmpty ? undetermined : d.first.language));
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 내장 자막은 '삭제' 표시(출력에서 제외), 외부 자막은 목록에서 제거
|
|
void removeSubtitle(VideoItem v, SubtitleEntry s) {
|
|
if (s.kind == SubtitleKind.embedded) {
|
|
s.enabled = !s.enabled;
|
|
} else {
|
|
v.subtitles.remove(s);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void setLanguage(SubtitleEntry s, Language lang) {
|
|
s.language = lang;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setCharset(SubtitleEntry s, String charset) {
|
|
s.charset = charset;
|
|
notifyListeners();
|
|
}
|
|
|
|
// ───────── 자막 편집 (2단계) ─────────
|
|
|
|
/// 편집 가능 여부 (이미지 자막은 불가)
|
|
bool canEdit(SubtitleEntry s) =>
|
|
!(s.kind == SubtitleKind.embedded && bitmapSubtitleCodecs.contains(s.codec)) &&
|
|
(s.kind == SubtitleKind.external || ffmpegVersion != null);
|
|
|
|
/// 자막을 큐 목록으로 불러온다 ([SubtitleFiles.loadCues])
|
|
Future<List<Cue>> loadCues(VideoItem v, SubtitleEntry s) => _files.loadCues(v, s);
|
|
|
|
/// 편집한 자막을 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 에 반영되도록 목록을 바꾼다 ([SubtitleFiles.saveEdited]).
|
|
/// 돌려주는 값: (저장 경로, 표현할 수 없어 '?' 로 바뀐 글자 수)
|
|
Future<(String, int)> saveEdited(VideoItem v, SubtitleEntry s, List<Cue> cues, String charset) async {
|
|
final (target, lost) = await _files.saveEdited(v, s, cues, charset);
|
|
_log(trf('자막 저장: {0} ({1}, {2}개 줄)' '{3}', [p.basename(target), charset, cues.length, lost > 0 ? trf(' ⚠ 표현할 수 없는 글자 {0}개', [lost]) : '']));
|
|
notifyListeners();
|
|
return (target, lost);
|
|
}
|
|
|
|
/// 다른 이름으로 저장 (목록은 바꾸지 않음)
|
|
Future<(String?, int)> saveCuesAs(VideoItem v, List<Cue> cues, String charset, String fileName) async {
|
|
final enc = encodeText(formatSrt(cues), charset);
|
|
final path = await services.storage.saveAs(fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory);
|
|
if (path != null) _log(trf('다른 이름으로 저장: {0} ({1})', [path, charset]));
|
|
return (path, enc.lostChars);
|
|
}
|
|
|
|
// ───────── AI 자막 (4단계) · 자막 번역 ([AiSubtitleRunner]) ─────────
|
|
|
|
bool get aiAvailable => services.createRecognizer != null && services.createTranslator != null;
|
|
|
|
/// 마지막으로 고른 AI 자막 설정
|
|
AiOptions aiOptions = AiOptions.defaults();
|
|
|
|
/// [targets] 동영상들의 AI 자막 만들기 (하나씩 차례로). 다른 작업 중이면 대기열로.
|
|
Future<void> generateAiSubtitles(List<VideoItem> targets, AiOptions opts) async {
|
|
if (!aiAvailable || ffmpegVersion == null || targets.isEmpty) return;
|
|
aiOptions = opts;
|
|
settings
|
|
..mkv.aiSource = opts.source.code
|
|
..mkv.aiTargets = [for (final t in opts.targets) t.code]
|
|
..mkv.aiWhisper = opts.whisper.id;
|
|
_saveSettings();
|
|
if (busy) {
|
|
for (final v in targets) {
|
|
v.phase = tr('AI 자막 대기 중');
|
|
}
|
|
}
|
|
final label = targets.length == 1 ? trf('AI 자막: {0}', [targets.first.fileName]) : trf('AI 자막 {0}개', [targets.length]);
|
|
return _jobs.enqueue(label, () => _ai.usingModels(() => _ai.run(targets, opts)));
|
|
}
|
|
|
|
/// 260: AI 자막 · 자막 번역이 모델을 쓰는 중 (그동안 환경 설정에서 그 모델을 지우지 못하게)
|
|
bool get aiModelsInUse => _ai.modelsInUse;
|
|
|
|
/// 자막 [s] 를 [targets] 언어로 번역해 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 자막에 추가.
|
|
/// 원어는 자막의 언어 태그, 없으면 글자로 추정. 이 PC 안에서 NLLB 로 번역 (다른 작업 중이면 대기열로).
|
|
Future<void> translateSubtitle(VideoItem v, SubtitleEntry s, Set<Language> targets) async {
|
|
if (services.createTranslator == null || targets.isEmpty) return;
|
|
if (busy) v.phase = tr('자막 번역 대기 중');
|
|
notifyListeners();
|
|
return _jobs.enqueue(trf('자막 번역: {0} → {1}', [v.fileName, targets.map((t) => t.code).join('/')]),
|
|
() => _ai.usingModels(() => _ai.translate(v, s, targets)));
|
|
}
|
|
|
|
// ───────── 인터넷 자막 (5단계 - [SubtitleDownload]) ─────────
|
|
|
|
late final List<SubtitleProvider> subtitleProviders =
|
|
services.createSubtitleProviders?.call(() => settings) ?? const [];
|
|
|
|
SubtitleProvider? get subtitleProvider => subtitleProviders.isEmpty ? null : subtitleProviders.first;
|
|
|
|
/// 파일명으로 검색 조건 추정 + 영상 해시 계산
|
|
Future<SubtitleQuery> guessSubtitleQuery(VideoItem v, List<Language> langs) => _download.queryFor(v, langs);
|
|
|
|
Future<List<SubtitleSearchResult>> searchSubtitles(SubtitleQuery q) async {
|
|
final p0 = subtitleProvider;
|
|
return p0 == null ? const [] : _download.search(p0, q);
|
|
}
|
|
|
|
/// 고른 자막 받기 → jj_mkv\파일명_언어코드.srt (UTF-8) 저장 → MKV 자막에 추가. 돌려주는 값: 받은 개수
|
|
/// [translateTo] 를 주면 받은 자막을 그 언어로 번역해 함께 추가 (대기열, 기다리지 않음)
|
|
Future<int> downloadSubtitles(VideoItem v, List<SubtitleSearchResult> picks, {Language? translateTo}) async {
|
|
final provider = subtitleProvider;
|
|
if (provider == null) return 0;
|
|
var queuedTranslate = false;
|
|
final ok = await _download.download(v, picks, provider, onAdded: (entry, r) {
|
|
if (translateTo != null &&
|
|
!queuedTranslate &&
|
|
r.language.code != translateTo.code &&
|
|
!v.subtitles.any((x) => x.enabled && x.language.code == translateTo.code)) {
|
|
queuedTranslate = true; // 한 번만 (여러 언어를 받아도 첫 자막에서 번역)
|
|
unawaited(translateSubtitle(v, entry, {translateTo}));
|
|
}
|
|
notifyListeners();
|
|
});
|
|
notifyListeners();
|
|
return ok;
|
|
}
|
|
|
|
// ───────── 동영상 재생 ─────────
|
|
|
|
/// 재생 준비. 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null,
|
|
/// 아니면 내장 플레이어용 (목록, 시작 위치).
|
|
/// [keepOrder]: 여러 개를 줄 때 이름순으로 다시 정렬하지 않고 준 순서대로 재생
|
|
/// [internal]: 외부 프로그램 설정과 상관없이 내장 플레이어로 (파일 탐색기의 "내장 플레이어로 재생")
|
|
/// [external]: 외부 프로그램으로 열 때 (화면이 WebDAV 로그인 안내 등을 한다 - 55). 없으면 바로 연다
|
|
Future<(List<String>, int)?> preparePlayback(List<String> files,
|
|
{bool keepOrder = false,
|
|
bool internal = false,
|
|
Future<void> Function(String program, List<String> videos)? external}) =>
|
|
_play.prepare(files, settings.playback, keepOrder: keepOrder, internal: internal, external: external);
|
|
|
|
/// 플레이어에 넘길 자막 파일 (Android 는 UTF-8 · SRT 사본 - [SubtitleFiles.playable])
|
|
Future<String> playableSubtitle(String path) => _files.playable(path, android: Platform.isAndroid);
|
|
|
|
/// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt
|
|
Future<List<String>> externalSubtitlesFor(String video) => _files.externalFor(video);
|
|
|
|
// ───────── MKV 만들기 ([MkvBatch]) ─────────
|
|
|
|
/// 목록의 MKV 만들기 (다른 작업 중이면 대기열로)
|
|
/// [onExisting]: 결과 MKV 가 이미 있을 때 'overwrite' · 'skip' · 'rename' (없으면 환경 설정, "묻기" 인데 묻지 못한 곳은 'rename')
|
|
Future<void> buildAll({String? onExisting}) async {
|
|
if (ffmpegVersion == null) return;
|
|
return _jobs.enqueue(tr('MKV 만들기'), () => _runBuild(null, onExisting));
|
|
}
|
|
|
|
/// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로)
|
|
Future<void> buildVideos(List<VideoItem> targets, {String? onExisting}) async {
|
|
if (ffmpegVersion == null || targets.isEmpty) return;
|
|
final label = targets.length == 1 ? trf('MKV 만들기: {0}', [targets.first.fileName]) : trf('MKV 만들기 {0}개', [targets.length]);
|
|
return _jobs.enqueue(label, () => _runBuild(targets, onExisting));
|
|
}
|
|
|
|
Future<void> _runBuild(List<VideoItem>? only, String? onExisting) {
|
|
final asked = onExisting ?? settings.mkv.existing;
|
|
return _mkv.run(
|
|
only?.where(videos.contains).toList() ?? videos.where((v) => v.status != JobStatus.done).toList(),
|
|
// P0: "묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름'
|
|
existing: const ['overwrite', 'skip', 'rename'].contains(asked) ? asked : 'rename',
|
|
encode: encode,
|
|
encoders: encoders,
|
|
limit: settings.mkv.maxParallelJobs,
|
|
);
|
|
}
|
|
|
|
/// P0: 결과 MKV 가 이미 있는 동영상 (만들기 전에 덮어쓸지 묻는 데)
|
|
Future<List<VideoItem>> withExistingOutput(List<VideoItem> targets) => _mkv.withExistingOutput(targets);
|
|
|
|
/// 지금 MKV 를 만드는 중인지 (32: AI 자막 · 번역만 돌 때는 인코딩 설정을 막지 않는다)
|
|
bool get buildingMkv => _mkv.building;
|
|
set buildingMkv(bool v) => _mkv.building = v;
|
|
|
|
/// P0-4: 지금 MKV 묶음의 전체 수 · 시작한 수 (진행 글 "3개 중 2번째")
|
|
int get batchTotal => _mkv.total;
|
|
int get batchStarted => _mkv.started;
|
|
|
|
/// 262: [v] 가 지금 만드는 묶음에 들어 있는지
|
|
bool inCurrentBatch(VideoItem v) => _mkv.contains(v);
|
|
|
|
/// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수
|
|
int get batchWaiting => _mkv.waiting;
|
|
|
|
/// 186: 지금 만드는 동영상 (취소 창에 보일 것)
|
|
List<VideoItem> get runningVideos => [for (final v in videos) if (v.status == JobStatus.running) v];
|
|
|
|
/// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업 · 동시에 도는 다른 동영상도 그대로).
|
|
/// 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것
|
|
List<VideoItem> cancelVideos(List<VideoItem> targets) => _mkv.cancelVideos(targets);
|
|
|
|
/// 45: 지금 작업만 멈추고 대기 중인 작업은 이어서 (다음 작업이 시작하면 취소 표시는 다시 꺼진다)
|
|
void cancelCurrent() {
|
|
if (!busy) return;
|
|
_log(trf('지금 작업 취소: {0}', [currentJob ?? '']));
|
|
_stopNow();
|
|
}
|
|
|
|
/// 지금 작업 중단 + 대기 중인 작업 모두 비우기
|
|
void cancel() {
|
|
final n = _jobs.cancelPending();
|
|
if (n > 0) _log(trf('대기 중인 작업 {0}개 취소', [n]));
|
|
for (final v in videos) {
|
|
if (v.status != JobStatus.running) v.phase = null;
|
|
}
|
|
_stopNow();
|
|
}
|
|
|
|
void _stopNow() {
|
|
_mkv.cancelAll();
|
|
_ai.cancel();
|
|
_tool.cancel();
|
|
}
|
|
|
|
/// 완료·실패 상태를 초기화해 다시 만들 수 있게 한다.
|
|
void resetStatus(VideoItem v) {
|
|
// 262: 지금 묶음의 것은 묶음이 끝난 뒤에 (끝나는 순간 ■ 자리에 ⟳ 가 나타나 ■ 를 누르려던 것이 ⟳ 로 들어갔다)
|
|
if (v.status == JobStatus.running || inCurrentBatch(v)) return;
|
|
v
|
|
..status = JobStatus.ready
|
|
..progress = 0
|
|
..message = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때)
|
|
void note(String msg) => _log(msg);
|
|
|
|
/// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log - [AppLog])
|
|
String? get logFile => _logBook.file;
|
|
set logFile(String? v) => _logBook.file = v;
|
|
|
|
void _log(String msg) {
|
|
_logBook.add(msg);
|
|
notifyListeners();
|
|
}
|
|
}
|