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 '../core/shizuku.dart' show PrivilegedFs; import '../services/platform_services.dart'; import 'app_session.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}) { // 설계 M3: 시스템 폴더 · 루트 읽기는 앱 조립이 넘긴 것으로 (넘기지 않으면 그대로) if (services.privileged != null) PrivilegedFs.use(services.privileged!); } /// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용 Future 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); /// 앱을 켜 둔 동안만 기억하는 화면 상태 (설계 M5 - 예전 화면 static) final session = AppSession(); 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 updateSettings(void Function(AppSettings s) change) async { change(settings); _applySettings(); notifyListeners(); await settingsStore?.save(settings); } /// 안전 저장소에 저장하지 못한 비밀번호를 다시 저장 (화면 위 알림의 [다시 시도] - 40-1 · 40-3) Future 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 get videos => _list.items; List 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 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 encoders = {}; Future 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 pickVideos() async { final paths = await services.storage.pickVideos(); // 33: 사용자가 직접 고른 파일은 jj_mkv 폴더 안에 있어도 넣는다 await addVideos(paths, allowOutputFolder: true); } /// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다 Future addVideos(List paths, {bool allowOutputFolder = false}) => _list.add(paths, allowOutputFolder: allowOutputFolder); /// 동영상 목록 저장 · 창끼리 공유 시작 ([file] 을 먼저 불러온다 - [VideoListShare]) Future shareVideoList(String file) => _share.start(file); void stopVideoListShare() => _share.stop(); /// 파일이 바뀌었으면 (다른 창에서 넣거나 지움) 이 창의 목록을 맞춘다 Future syncVideoList() => _share.sync(); /// 116 (Android): 저장소 전체 접근 권한이 있는지 (null = 묻지 않음 - Windows) Future Function()? fileAccess; /// 116: 분석에 실패했던 동영상을 다시 분석한다 (권한을 허용하고 돌아왔을 때). 다시 분석한 수 Future reanalyzeFailed() => _list.reanalyzeFailed(); Future _loadVideo(VideoItem v) async { await _probe.load(v); notifyListeners(); } void select(VideoItem v) => _list.select(v); /// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상) Set get checked => _list.checked; void toggleChecked(VideoItem v) => _list.toggleChecked(v); /// 모두 체크되어 있으면 모두 해제, 아니면 모두 체크 void toggleAllChecked() => _list.toggleAllChecked(); /// 일괄 작업 대상: 체크한 동영상 (목록 순서대로). 체크한 것이 없으면 지금 보고 있는 한 개. List get batchTargets => _list.batchTargets; /// 체크한 동영상을 목록에서 제거 (작업 중인 것은 남긴다) void removeChecked() => _list.removeChecked(); /// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤) Future aiThenBuild(List targets, AiOptions opts, {String? onExisting}) async { await generateAiSubtitles(targets, opts); if (_ai.cancelled) return; await buildVideos(targets, onExisting: onExisting); } /// 다 받은 동영상을 편집 목록에 추가 (이미 있으면 건너뜀). 새로 넣은 개수. Future addDownloaded(List 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)> moveVideos(List targets, String dest) => _list.move(targets, dest); void removeVideo(VideoItem v) => _list.remove(v); void clearVideos() { if (!busy) _list.clear(); } // ───────── 자막 ───────── Future 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> loadCues(VideoItem v, SubtitleEntry s) => _files.loadCues(v, s); /// 편집한 자막을 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 에 반영되도록 목록을 바꾼다 ([SubtitleFiles.saveEdited]). /// 돌려주는 값: (저장 경로, 표현할 수 없어 '?' 로 바뀐 글자 수) Future<(String, int)> saveEdited(VideoItem v, SubtitleEntry s, List 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 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 generateAiSubtitles(List 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 translateSubtitle(VideoItem v, SubtitleEntry s, Set 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 subtitleProviders = services.createSubtitleProviders?.call(() => settings) ?? const []; SubtitleProvider? get subtitleProvider => subtitleProviders.isEmpty ? null : subtitleProviders.first; /// 파일명으로 검색 조건 추정 + 영상 해시 계산 Future guessSubtitleQuery(VideoItem v, List langs) => _download.queryFor(v, langs); Future> searchSubtitles(SubtitleQuery q) async { final p0 = subtitleProvider; return p0 == null ? const [] : _download.search(p0, q); } /// 고른 자막 받기 → jj_mkv\파일명_언어코드.srt (UTF-8) 저장 → MKV 자막에 추가. 돌려주는 값: 받은 개수 /// [translateTo] 를 주면 받은 자막을 그 언어로 번역해 함께 추가 (대기열, 기다리지 않음) Future downloadSubtitles(VideoItem v, List 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, int)?> preparePlayback(List files, {bool keepOrder = false, bool internal = false, Future Function(String program, List videos)? external}) => _play.prepare(files, settings.playback, keepOrder: keepOrder, internal: internal, external: external); /// 플레이어에 넘길 자막 파일 (Android 는 UTF-8 · SRT 사본 - [SubtitleFiles.playable]) Future playableSubtitle(String path) => _files.playable(path, android: Platform.isAndroid); /// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt Future> externalSubtitlesFor(String video) => _files.externalFor(video); // ───────── MKV 만들기 ([MkvBatch]) ───────── /// 목록의 MKV 만들기 (다른 작업 중이면 대기열로) /// [onExisting]: 결과 MKV 가 이미 있을 때 'overwrite' · 'skip' · 'rename' (없으면 환경 설정, "묻기" 인데 묻지 못한 곳은 'rename') Future buildAll({String? onExisting}) async { if (ffmpegVersion == null) return; return _jobs.enqueue(tr('MKV 만들기'), () => _runBuild(null, onExisting)); } /// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로) Future buildVideos(List 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 _runBuild(List? 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> withExistingOutput(List 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 get runningVideos => [for (final v in videos) if (v.status == JobStatus.running) v]; /// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업 · 동시에 도는 다른 동영상도 그대로). /// 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것 List cancelVideos(List 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(); } }