JJ_MKVMaker/lib/app/app_controller.dart

499 lines
23 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/models.dart';
import '../core/srt.dart';
import '../core/subtitle_search.dart';
import '../services/subtitle_provider.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);
// ───────── 모듈 ─────────
late final _logBook = AppLog(now: services.clock);
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, services.vault.viewRoot, _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 {
await _files.pickFor(v);
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) => SubtitleFiles.canEdit(s, hasFfmpeg: 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 (path, lost) = await _files.saveAs(v, cues, charset, fileName);
if (path != null) _log(trf('다른 이름으로 저장: {0} ({1})', [path, charset]));
return (path, lost);
}
// ───────── 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();
}
/// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때). [at] 적은 때 (처음 값 = 지금 - [AppLog.add] 와 같음)
void note(String msg, {DateTime? at}) => _log(msg, at: at);
/// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log - [AppLog])
String? get logFile => _logBook.file;
set logFile(String? v) => _logBook.file = v;
void _log(String msg, {DateTime? at}) {
_logBook.add(msg, at: at);
notifyListeners();
}
}