main.dart 나누기 (939 → 236): 시작 단계를 lib/startup/ 모듈로, 앱 위젯은 ui/app_root

- Windows · Android 가 복사해 쓰던 시작 단계 (startCore) 와 다운로드 연결 (createDownloads) 을 하나로 - 차례는 그대로
- Windows 끝내기 (WindowsQuit) · 실행 요청 처리 (handleLaunchRequest) · Android 시작 · 두 번째 창 · 도우미
- main.dart 는 옮긴 공개 이름을 그대로 내보낸다 (시험 · 다른 곳)
- 약속 시험: createDownloads · lastWorkText

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 02:58:19 +09:00
parent 1689221019
commit 49745c0d52
9 changed files with 897 additions and 725 deletions

View File

@ -1,48 +1,24 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart' show CupertinoLocalizations, DefaultCupertinoLocalizations;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:window_manager/window_manager.dart';
import 'app/app_controller.dart';
import 'app/live_sync.dart';
import 'app/model_backup.dart';
import 'app/version_snapshot.dart';
import 'app/install_marker.dart';
import 'app/component_store.dart';
import 'app/net_guard.dart';
import 'app/tor_service.dart';
import 'app/web_proxy.dart';
import 'ui/added_to_mkv_notice.dart';
import 'ui/settings_problem.dart';
import 'ui/exit_guard.dart' show confirmStopCopies;
import 'app/copy_center.dart';
import 'ui/migration_notice.dart';
import 'app/ai_local.dart';
import 'ui/license_texts.dart';
import 'platform/windows/data_dir_override.dart';
import 'ui/master_prompt.dart';
import 'ui/secret_issue.dart';
import 'ui/sync_alert.dart';
import 'app/bookmarks_controller.dart';
import 'app/download_manager.dart';
import 'app/settings.dart';
import 'core/app_update.dart';
import 'core/playlist.dart';
import 'core/sftp.dart' show SftpRegistry;
import 'core/ftp.dart' show FtpRegistry;
import 'core/smb.dart' show SmbRemote;
import 'platform/android/android_download_tools.dart';
import 'platform/android/android_keep_alive.dart';
import 'platform/android/android_shell.dart' show applyScreenOrientation;
import 'platform/android/android_storage.dart';
import 'ui/all_files_guide.dart';
import 'platform/windows/app_paths.dart';
import 'platform/windows/cef_runtime.dart';
import 'platform/windows/com_guard.dart';
@ -53,28 +29,27 @@ import 'platform/windows/single_instance.dart';
import 'platform/windows/start_menu.dart';
import 'platform/windows/window_memory.dart';
import 'services/platform_services.dart';
import 'ui/ai_dialog.dart';
import 'ui/app_actions.dart';
import 'ui/app_drop.dart';
import 'ui/browser_page.dart';
import 'ui/downloads_page.dart';
import 'ui/exit_dialog.dart';
import 'ui/home_page.dart';
import 'ui/monitor_page.dart' show askLiveSyncStart, MonitorPage;
import 'ui/network_settings.dart' show DnsFailureNotice;
import 'ui/player_page.dart';
import 'ui/setup_dialog.dart';
import 'ui/update_dialog.dart';
import 'ui/version_restore.dart';
import 'ui/theme.dart';
import 'ui/work_panel.dart';
import 'l10n/tr.dart';
import 'app/i18n_controller.dart';
import 'platform/android/android_updater.dart';
import 'ui/notice.dart';
import 'ui/app_root.dart';
import 'startup/startup_helpers.dart';
import 'startup/android_start.dart';
import 'startup/second_window.dart';
import 'startup/app_start.dart';
import 'startup/launch_requests.dart';
import 'startup/windows_quit.dart';
/// 제목 표시줄 글: "JJ_MKVMaker v1.2.3"
String appTitle = 'JJ_MKVMaker';
// 시험 · 다른 곳이 main.dart 로 부르던 이름 (그 모듈에 있음)
export 'ui/app_root.dart';
export 'startup/startup_helpers.dart';
export 'startup/android_start.dart' show runAndroid;
export 'startup/second_window.dart' show runSecondWindow;
/// 버전을 읽어 제목을 정하고, 설정 폴더를 돌려준다
Future<String> _prepare() async {
@ -139,121 +114,24 @@ Future<void> main(List<String> args) async {
}
// 버전별 설정 보관 (업데이트 · 예전 버전으로 되돌리기): 시작할 때 설정 파일이 없었는지 (새로 설치)
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
ComponentStore.dataDirectory = dataDir;
AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai)
AiStore.log = controller.note;
VersionSnapshot.instance = VersionSnapshot(dataDir);
await controller.init();
await setupNetwork(controller, dataDir); // DNS 강제 · Tor (인터넷을 쓰는 다른 것보다 먼저)
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.display.language, save: false);
// 공용 시작 단계 (Android 와 같은 차례 - app_start)
final (:live, :bookmarks) = await startCore(controller, dataDir);
final bookmarks = BookmarksController();
await bookmarks.load();
final downloads = DownloadManager(
backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
settings: () => controller.settings,
);
final navigatorKey = GlobalKey<NavigatorState>();
final messengerKey = GlobalKey<ScaffoldMessengerState>();
final downloads = createDownloads(controller, messengerKey: messengerKey, navigatorKey: navigatorKey);
attachMasterPrompt(navigatorKey);
final memory = WindowMemory(p.join(dataDir, 'window.json'), 'main');
/// 종료 요청. true 를 돌려주면 프로그램을 끝낸다.
/// - 창 ✕ · 종료 버튼: 설정 "종료를 누르면" 에 따라 백그라운드로 (창만 숨김) / 묻지 않고 종료 / 고르기
/// - 트레이 "종료" · 업데이트 설치 ([force]): 항상 종료 (받는 중인 다운로드가 있으면 확인)
Future<bool> confirmQuit({bool force = false}) async {
final shell = services.shell;
final desktop = shell is DesktopShell ? shell : null;
var mode = force || (desktop?.closeFromTray ?? false) ? 'force' : controller.settings.run.closeAction;
// 트레이 아이콘이 없으면 숨긴 창을 끝낼 방법이 없다 → 고르게 한다
if (mode == 'background' && !(desktop?.hasTray ?? false)) mode = 'ask';
if (mode == 'ask') {
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return false;
final n = downloads.activeCount;
final pick = await showCloseChoice(ctx,
running: [
if (controller.currentJob != null) controller.currentJob!,
if (n > 0) trf('다운로드 {0}개', [n]),
if ((CopyCenter.peekOf(controller)?.activeCount ?? 0) > 0)
trf('복사 · 이동 {0}개', [CopyCenter.peekOf(controller)!.activeCount]),
].join(' · ').ifEmptyText(tr('진행 중인 작업 없음')),
hotkey: controller.settings.run.showHotkey);
if (pick == null) return false;
if (pick.$2) controller.updateSettings((x) => x.run.closeAction = pick.$1);
mode = pick.$1;
}
if (mode == 'background') {
await shell.hide();
return false;
}
if (mode == 'force') {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return false;
}
// 43: 복사 · 이동 중이면 (설정이 "묻지 않고 종료" 여도) 한 번 묻는다
{
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmStopCopies(ctx, controller)) return false;
}
ExitTrace.start();
final job = controller.currentJob;
final wait = controller.pendingJobs.length;
final steps = [
ExitStep(job == null ? tr('작업 확인 (진행 중인 작업 없음)') : trf('작업 중지: {0}{1}', [job, wait > 0 ? trf(' · 대기 {0}개', [wait]) : '']),
() async => controller.cancel()),
ExitStep(tr('다운로드 정리 (yt-dlp · aria2 종료)'), downloads.shutdown),
ExitStep(tr('Tor 끄기'), () => TorService.instance.stop()),
// 228: 다음 실행이 이 창에 넘기려 하지 않게 (instance.json 지우기 · 잠금 파일 놓기)
ExitStep(tr('중복 실행 막기 정리'), instance.close),
ExitStep(tr('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)'), () async {
while (controller.busy) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}),
ExitStep(tr('동영상 목록 · 창 위치 저장'), () async {
controller.stopVideoListShare();
await memory.save();
controller.note(tr('── 정상 종료 ──'));
session.markClean();
}),
ExitStep(tr('창 종료'), () async {}),
];
final ctx2 = navigatorKey.currentContext;
if (ctx2 != null && ctx2.mounted) {
await showExitProgress(ctx2, lastWork: lastWorkText(controller), steps: [
for (final s in steps)
ExitStep(s.label, () async {
try {
await s.run();
} finally {
ExitTrace.mark(trf('끝: {0}', [s.label]));
}
}),
]);
ExitTrace.mark(tr('종료 창 닫힘'));
} else {
for (final s in steps) {
try {
await s.run().timeout(const Duration(seconds: 8));
} catch (_) {}
}
}
return true;
}
/// 종료 요청 (true 면 프로그램을 끝낸다 - [WindowsQuit])
final confirmQuit = WindowsQuit(
controller: controller, downloads: downloads, navigatorKey: navigatorKey, instance: instance, memory: memory, session: session);
await services.shell.init(
hotkey: controller.settings.run.showHotkey,
minimizeToTray: () => controller.settings.run.minimizeToTray,
onCloseRequested: confirmQuit,
onCloseRequested: confirmQuit.call,
onDownloadsRequested: () {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(DownloadsPage.open(nav, downloads));
@ -281,9 +159,7 @@ Future<void> main(List<String> args) async {
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
watchDnsFailure(messengerKey, controller);
// 클립보드로 추가된 다운로드 알림 · 트레이 글
downloads.onAdded.listen((t) => messengerKey.currentState
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
// 트레이 글
// 92: 트레이에 둔 채 실시간 동기화가 멈추면 Windows 알림 · 트레이 툴팁, 누르면 모니터링 > lsync 카드로
final syncAlert = SyncAlert(live, services.shell,
toastEnabled: () => controller.settings.copy.syncStopToast,
@ -296,25 +172,6 @@ Future<void> main(List<String> args) async {
return n > 0 ? trf('JJ_MKVMaker - 다운로드 {0}개', [n]) : 'JJ_MKVMaker';
});
downloads.addListener(() => services.shell.setTooltip(syncAlert.tooltip()));
// 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음)
downloads.onFinished.listen((t) async {
if (!controller.settings.download.addFinished) return;
final files = DownloadManager.videoFilesOf(t);
if (files.isEmpty) return;
final n = await controller.addDownloaded(files);
// MKV 만들기 목록으로 넘어갔으므로 다운로드 목록에서는 뺀다 (받은 파일은 그대로)
downloads.dropFromList(t);
// 줄이 사라진 이유와 [보기] (MKV 화면 · 그 동영상)
showAddedToMkvNotice(messengerKey, navigatorKey, controller, title: t.title, files: files, added: n);
});
downloads.setAutoAdd = (on) async {
await controller.updateSettings((x) => x.download.addFinished = on);
downloads.refresh();
};
downloads.useSystemDns = () async {
await controller.updateSettings((x) => x.network.mode = 'system');
downloads.refresh();
};
// COM 준비 상태 지킴이: 풀려 있으면 (앱 안 브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨는 원인)
// 다시 준비하고 작업 기록에 남긴다. 바로 앞의 기록을 보면 무엇을 한 뒤에 풀렸는지 알 수 있다.
Timer.periodic(const Duration(seconds: 3), (_) {
@ -322,61 +179,10 @@ Future<void> main(List<String> args) async {
controller.note(tr('⚠ Windows COM 준비 상태가 풀려 있어 다시 준비했습니다 (브라우저 · 끌어다 놓기에 필요)'));
}
});
downloads.addToEditList = controller.addDownloaded;
downloads.log = controller.note;
unawaited(downloads.startClipboardWatch());
// 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리
instance.listen((req) async {
await services.shell.show();
// 99: 동기화가 멈췄다는 알림을 누름 → 실시간 동기화 화면
if (req.action == LaunchAction.lsync) {
for (var i = 0; i < 100 && navigatorKey.currentState == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final nav = navigatorKey.currentState;
if (nav != null) unawaited(nav.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: controller, initialTab: 1))));
return;
}
if (req.files.isEmpty) return;
// 프로그램을 파일과 함께 처음 켠 경우: 첫 화면이 뜰 때까지 (최대 10초) 기다린다
for (var i = 0; i < 100 && navigatorKey.currentContext == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
switch (req.action) {
case LaunchAction.play:
// 재생한 동영상도 MKV 화면의 목록에 넣는다
unawaited(controller.addVideos(req.files.where(isVideoFile).toList(), allowOutputFolder: true));
await playFiles(ctx, controller, req.files);
case LaunchAction.subtitle:
await controller.addVideos(req.files.where(isVideoFile).toList(), allowOutputFolder: true);
final targets = controller.videos
.where((v) => req.files.any((f) => p.equals(f, v.path)))
.toList();
if (targets.isNotEmpty && ctx.mounted) {
controller.select(targets.first);
await showAiDialog(ctx, controller, targets.first, targets: targets);
}
case LaunchAction.lsync:
break; // 위에서 처리
case LaunchAction.add:
// 탐색기 더블클릭 · 연결 프로그램: 설정에 따라 재생 또는 편집 목록에 추가
final videos = req.files.where(isVideoFile).toList();
if (videos.isEmpty) return;
// 어느 쪽이든 MKV 화면의 목록에는 들어간다
final added = controller.addVideos(videos, allowOutputFolder: true);
if (controller.settings.playback.openFileAction == 'add') {
await added;
final first = controller.videos.where((v) => videos.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) controller.select(first);
} else {
unawaited(added);
await playFiles(ctx, controller, videos);
}
}
});
instance.listen((req) => handleLaunchRequest(req, controller, navigatorKey));
controller.confirmQuit = () => confirmQuit(force: true); // 업데이트 설치: 항상 종료
@ -406,7 +212,7 @@ Future<void> main(List<String> args) async {
// 업데이트로 예전 기본값을 새 기본값으로 옮겼으면 한 번 알린다
if (ctx.mounted) await showMigrationNotice(ctx, controller);
// 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지
final current = await _currentVersion(services);
final current = await currentAppVersion(services);
if (ctx.mounted) {
await checkVersionRestore(ctx, controller, current: current, freshInstall: freshInstall, restart: services.shell.restart);
}
@ -428,512 +234,3 @@ Future<void> main(List<String> args) async {
));
}
/// Android: 다른 앱 (파일 앱 "다음으로 열기" · 공유) 에서 받은 동영상.
/// 설정 "다른 앱에서 동영상을 열 때" 에 따라 바로 재생 (기본) 또는 MKV 목록에 추가. 실제 경로를 모르는 것은 재생만.
Future<void> openFromOtherApp(
GlobalKey<NavigatorState> navigatorKey, AppController c, List<Map<Object?, Object?>> files) async {
final paths = [for (final f in files) f['path'] as String?].whereType<String>().toList();
final uris = [
for (final f in files)
if (f['path'] == null) f['uri'] as String,
];
c.note(trf('다른 앱에서 열기: {0}', [files.map((f) => f['name'] ?? f['uri']).join(', ')]));
// MKV 화면으로 돌아온 뒤 (재생 중이었다면 그 화면은 닫는다)
navigatorKey.currentState?.popUntil((r) => r.isFirst);
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
if (paths.isNotEmpty) {
final added = c.addVideos(paths, allowOutputFolder: true);
if (c.settings.playback.openFileAction == 'add') {
await added;
final first = c.videos.where((v) => paths.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) c.select(first);
} else {
unawaited(added);
await playFiles(ctx, c, paths);
}
} else if (uris.isNotEmpty) {
// 파일 위치를 알려 주지 않는 앱: 재생만 (MKV 만들기 · 자막은 파일 고르기로 추가해야 함)
ScaffoldMessenger.maybeOf(ctx)?.showSnackBar(SnackBar(
content: Text(tr('파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 "동영상 추가" 로 고르세요.'))));
final create = c.services.createMediaPlayer;
if (create != null) {
await Navigator.of(ctx).push(MaterialPageRoute<void>(
builder: (_) => PlayerPage(c: c, player: create(), files: uris, start: 0)));
}
}
}
/// DNS 강제 (사용자 요청 10/10) · Tor 준비: 앱의 HTTP 가 정한 DNS 를 쓰게 하고, 설정이 바뀌면 다시 건다.
/// [second] (재생만 하는 새 창) 은 설정을 읽기만 한다
Future<void> setupNetwork(AppController c, String dataDir, {bool second = false}) async {
final g = NetGuard.instance
..config = (() => c.settings.network.config)
..log = c.note;
try {
await g.ensure();
} catch (e) {
c.note(trf('DNS 강제 프록시를 시작하지 못했습니다: {0}', [e]));
}
HttpOverrides.global = JjHttpOverrides();
// 88: SFTP 는 HTTP 가 아니라 직접 잇는다 - 같은 DNS 설정을 따르게 (집 안 이름 · 주소는 기기 DNS)
SftpRegistry.connect = SftpRegistry.connectWithDns(g.resolver);
FtpRegistry.resolve = FtpRegistry.resolveWithDns(g.resolver);
SmbRemote.resolve = FtpRegistry.resolveWithDns(g.resolver);
if (second) return;
TorService.dataDir = dataDir;
TorService.instance.log = c.note;
if (Platform.isAndroid) {
WebProxy.log = c.note;
// 환경 설정 "다른 화면으로 가면 소리 멈춤" 이 꺼져 있으면 일반 브라우저가 뒤에 있어도 DNS 문을 열어 둔다
WebProxy.keepPlayingWhenHidden = () => !c.settings.browser.pauseOnLeave;
try {
await WebProxy.apply();
} catch (e) {
c.note(trf('웹 뷰 프록시를 걸지 못했습니다: {0}', [e]));
}
// 앱이 뒤로 가면 웹 뷰 프록시 문을 닫고, 돌아오면 다시 (관리자 결정 10/11 - 열려 있는 시간을 줄인다)
AppLifecycleListener(
onResume: () => unawaited(WebProxy.setForeground(true)),
onHide: () => unawaited(WebProxy.setForeground(false)),
);
}
var last = c.settings.network.config.key;
var lastPause = c.settings.browser.pauseOnLeave;
c.addListener(() async {
if (Platform.isAndroid && c.settings.browser.pauseOnLeave != lastPause) {
lastPause = c.settings.browser.pauseOnLeave;
await WebProxy.apply();
}
final now = c.settings.network.config.key;
if (now == last) return;
last = now;
c.note(trf('DNS 설정: {0}', [c.settings.network.mode == 'system' ? tr('시스템 DNS') : c.settings.network.config.label]));
await g.ensure();
if (Platform.isAndroid) await WebProxy.apply();
});
}
/// DNS 서버에 닿지 않으면 알린다 (기기 DNS 로 몰래 바꾸지 않음). [시스템 DNS 로 바꾸기] 를 함께
void watchDnsFailure(GlobalKey<ScaffoldMessengerState> messengerKey, AppController c) {
NetGuard.instance.failure.addListener(() {
final f = NetGuard.instance.failure.value;
if (f == null) return;
// 248: 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열려 있으면 띄우지 않는다 (상태는 설정 · 작업 기록 줄에)
if (NetGuard.instance.noticeBlockers.value > 0) return;
showNotice(
messengerKey.currentState,
// 248: 버튼이 있는 안내라 사용자가 닫거나 누를 때까지 둔다 (한 번만 뜨고, 상태는 설정 · 작업 현황에 남음 - 249)
life: NoticeLife.untilClosed,
// 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError).
// 상태가 풀리거나 같은 안내를 보이는 화면이 열리면 스스로 닫힌다 (248)
content: DnsFailureNotice(failure: f),
action: (tr('시스템 DNS 로 바꾸기'), () => c.updateSettings((x) => x.network.mode = 'system')),
);
});
}
/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서")
bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) =>
s.playback.openFileWindow == 'new' &&
r.files.any(isVideoFile) &&
(r.action == LaunchAction.play || (r.action == LaunchAction.add && s.playback.openFileAction == 'play'));
extension on String {
String ifEmptyText(String other) => isEmpty ? other : this;
}
/// 지금 실행 중인 버전 (예: 2026.10.05_003). 모르면 ''.
Future<String> _currentVersion(PlatformServices services) async {
try {
return await services.updater?.currentVersion() ?? '';
} catch (_) {
return '';
}
}
/// 종료 창에 보일 "바로 전 작업": 하던 작업, 없으면 마지막 작업 기록 (시각 표시는 뺀다)
String lastWorkText(AppController c) {
if (c.currentJob != null) return c.currentJob!;
if (c.logs.isEmpty) return tr('없음');
return c.logs.last.replaceFirst(RegExp(r'^\[[\d:]+\]\s*'), '').split('\n').first;
}
/// Android: 창 하나. 트레이 · 단축키 · 탐색기 연결 · 다운로드 (yt-dlp · aria2) · 업데이트는 없다.
/// 화면 방향은 기본이 자동 (기기를 돌리는 대로). 환경 설정 > 화면 방향에서 가로 · 세로 고정.
Future<void> runAndroid(String dataDir) async {
HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭)
final services = PlatformServices.create();
final logs = Directory(p.join(dataDir, 'Logs'))..createSync(recursive: true);
final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log');
controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
// 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때)
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
// 115: 다시 설치한 뒤 처음 켰는데 설정이 있으면 Android 자동 백업이 되살린 것 (표시 파일은 백업에서 뺐다)
final marker = InstallMarker(dataDir);
var autoBackup = false;
try {
final info = await PackageInfo.fromPlatform();
autoBackup = restoredByAutoBackup(
settingsExisted: !freshInstall, markerExists: marker.exists, installTime: info.installTime, updateTime: info.updateTime);
} catch (_) {}
marker.write();
if (autoBackup) controller.note(tr('Android 자동 백업에서 되살린 설정으로 시작합니다'));
ComponentStore.dataDirectory = dataDir;
AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai)
AiStore.log = controller.note;
String? sharedSnapshots;
try {
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot');
// l10n-skip: 실제 폴더 이름 (예전 보관을 찾아야 하므로 바꾸지 않음)
if (root != null) sharedSnapshots = p.join(root, 'Download', 'JJ_MKVMaker', '설정 보관');
if (root != null) ModelBackup.sharedRoot = p.join(root, 'Download', 'JJ_MKVMaker');
} catch (_) {}
VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots);
await controller.init();
await setupNetwork(controller, dataDir); // DNS 강제 · Tor · 웹 뷰 프록시 (인터넷을 쓰는 다른 것보다 먼저)
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.display.language, save: false);
await applyScreenOrientation(controller.settings.display.orientation);
unawaited(services.shell.setAppIcon(controller.settings.display.appIcon));
final bookmarks = BookmarksController();
await bookmarks.load();
final navigatorKey = GlobalKey<NavigatorState>();
final messengerKey = GlobalKey<ScaffoldMessengerState>();
AndroidStorageService.navigatorKey = navigatorKey;
attachMasterPrompt(navigatorKey);
// 다운로드 (앱 안 브라우저의 [다운로드] · 다운로드 목록): 앱에 넣은 yt-dlp · aria2c. 클립보드 감시는 하지 않는다.
AndroidDownloadTools.log = controller.note;
final downloads = DownloadManager(
backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
settings: () => controller.settings,
);
downloads.onAdded.listen((t) => messengerKey.currentState
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
watchDnsFailure(messengerKey, controller);
// 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음)
downloads.onFinished.listen((t) async {
if (!controller.settings.download.addFinished) return;
final files = DownloadManager.videoFilesOf(t);
if (files.isEmpty) return;
final n = await controller.addDownloaded(files);
downloads.dropFromList(t);
// 줄이 사라진 이유와 [보기] (MKV 화면 · 그 동영상)
showAddedToMkvNotice(messengerKey, navigatorKey, controller, title: t.title, files: files, added: n);
});
downloads.setAutoAdd = (on) async {
await controller.updateSettings((x) => x.download.addFinished = on);
downloads.refresh();
};
downloads.useSystemDns = () async {
await controller.updateSettings((x) => x.network.mode = 'system');
downloads.refresh();
};
downloads.addToEditList = controller.addDownloaded;
downloads.log = controller.note;
// 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)
AndroidKeepAlive(controller, downloads);
// 170: 업데이트 설치의 첫 시도 실패 원문을 작업 기록에
final updater = services.updater;
if (updater is AndroidUpdater) updater.log = controller.note;
// 백그라운드로 실행: 화면 (Activity) 을 닫아도 엔진은 살아 있다가 다시 열면 새 화면에 붙는다 → 화면 방향을 다시 적용
// (웹뷰는 텍스처로 그려 새 화면에 붙어도 그대로 보인다: browser_page _webSettings)
// 116: 권한 ("모든 파일에 대한 접근") 이 없어 분석에 실패한 동영상은 허용하고 돌아오면 저절로 다시 분석
controller.fileAccess = AndroidAccess.hasAllFiles;
var hadAccess = await AndroidAccess.hasAllFiles();
if (hadAccess) await allFilesGuideDue(controller, hasAccess: true);
AppLifecycleListener(onResume: () async {
await applyScreenOrientation(controller.settings.display.orientation);
final now = await AndroidAccess.hasAllFiles();
if (now && !hadAccess) await controller.reanalyzeFailed();
if (now) await allFilesGuideDue(controller, hasAccess: true);
hadAccess = now;
});
// 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
// 연결 프로그램 · 공유로 받은 동영상: 앱이 켜져 있을 때 (MainActivity.onNewIntent)
const android = MethodChannel('jj_mkvmaker/android');
android.setMethodCallHandler((call) async {
if (call.method == 'showJobs') {
// 작업 알림을 눌렀다: 작업 현황 화면으로
final nav = navigatorKey.currentState;
if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads));
return null;
}
if (call.method == 'openFiles') {
await openFromOtherApp(navigatorKey, controller, (call.arguments as List).cast<Map<Object?, Object?>>());
}
return null;
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
// 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터
try {
if (await android.invokeMethod<bool>('takeShowJobs') ?? false) {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads));
}
final opened = await android.invokeMethod<List<Object?>>('takeOpenedFiles') ?? const [];
if (opened.isNotEmpty) {
unawaited(openFromOtherApp(navigatorKey, controller, opened.cast<Map<Object?, Object?>>()));
return;
}
} catch (_) {}
// 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다.
// 39: 켤 때마다 띄우지 않고 한 번만. 그 뒤로는 파일 탐색기 위의 안내 · 동영상 항목의 안내가 알려 준다
// 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume)
final ctxA = navigatorKey.currentContext;
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
await showAllFilesGuide(ctxA, AndroidAccess.request, hasAccess: AndroidAccess.hasAllFiles);
}
// 124: "처음 시작 시" 면 마스터 비밀번호
final ctxM = navigatorKey.currentContext;
if (ctxM != null && ctxM.mounted) await askMasterAtStartup(ctxM, controller);
final home = AppNavButtons.homeId(controller.settings);
final nav0 = navigatorKey.currentState;
if (home != 'mkv' && nav0 != null) {
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
}
// 다시 켤 때 실시간 동기화를 골라서 시작
final ctx0 = navigatorKey.currentContext;
if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live);
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
final current = await _currentVersion(services);
final ctxV = navigatorKey.currentContext;
if (ctxV != null && ctxV.mounted) await showSettingsProblem(ctxV, controller);
watchSecretIssue(messengerKey, controller);
if (ctxV != null && ctxV.mounted) await showMigrationNotice(ctxV, controller);
if (ctxV != null && ctxV.mounted) {
await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall, autoBackup: autoBackup,
restart: () async {
messengerKey.currentState?.showSnackBar(SnackBar(content: Text(tr('설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.'))));
await Future<void>.delayed(const Duration(seconds: 2));
await services.shell.quit();
});
}
// P0: 되돌리느라 공용 폴더에 옮겨 둔 받은 AI 모델 되살리기
final ctxModels = navigatorKey.currentContext;
if (ctxModels != null && ctxModels.mounted) await restoreModelBackup(ctxModels, controller);
// 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음)
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller));
});
runApp(JjMkvMakerApp(
controller: controller,
downloads: downloads,
bookmarks: bookmarks,
navigatorKey: navigatorKey,
messengerKey: messengerKey,
onExit: () async {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return;
if (controller.busy && ctx != null && ctx.mounted) {
final ok = await showDialog<bool>(
context: ctx,
builder: (c) => AlertDialog(
title: Text(tr('종료')),
content: Text(trf('진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?', [controller.currentJob ?? ''])),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(c, true), child: Text(tr('종료'))),
],
),
);
if (ok != true) return;
}
controller.cancel();
await downloads.shutdown();
await TorService.instance.stop();
controller.note(tr('── 정상 종료 ──'));
await services.shell.quit();
},
));
}
/// 탐색기에서 연 동영상의 새 창: 바로 재생하고, ← 로 나오면 같은 동영상 목록의 MKV 화면.
/// 트레이 · 단축키 · 다운로드 · 브라우저는 원래 창이 맡는다. 닫으면 이 창만 끝난다.
/// 동영상 목록은 원래 창과 같은 파일을 보므로 어느 창에서 넣거나 지워도 서로 맞춰진다.
/// 설정은 읽기만 한다 (원래 창의 설정 파일을 덮어쓰지 않도록 저장하지 않음).
Future<void> runSecondWindow(List<String> files, String dataDir) async {
HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭)
await windowManager.ensureInitialized();
await windowManager.setTitle(appTitle);
await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch();
final controller = AppController(PlatformServices.create());
await controller.init();
await setupNetwork(controller, dataDir, second: true);
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.display.language, save: false);
final store = SettingsStore();
controller.settings = await store.load();
final navigatorKey = GlobalKey<NavigatorState>();
// 124: 이 창도 저장된 비밀번호 (WebDAV 재생) 를 쓰기 전에 마스터를 묻는다 (풀린 상태는 창마다)
await setupMasterLock(controller, secrets: store.secrets);
attachMasterPrompt(navigatorKey);
runApp(JjMkvMakerApp(controller: controller, navigatorKey: navigatorKey, onExit: endProcessNow));
WidgetsBinding.instance.addPostFrameCallback((_) async {
await controller.shareVideoList(p.join(dataDir, 'videos.json'));
unawaited(controller.addVideos(files, allowOutputFolder: true));
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) await playFiles(ctx, controller, files);
});
}
class JjMkvMakerApp extends StatelessWidget {
final AppController controller;
final DownloadManager? downloads;
final BookmarksController? bookmarks;
final GlobalKey<NavigatorState>? navigatorKey;
final GlobalKey<ScaffoldMessengerState>? messengerKey;
final VoidCallback? onExit;
const JjMkvMakerApp({
super.key,
required this.controller,
this.downloads,
this.bookmarks,
this.navigatorKey,
this.messengerKey,
this.onExit,
});
/// 140 · 158: Flutter 기본 글 (뒤로 · Licenses 등) 을 화면 언어로 - 앱 언어 코드 → Locale
/// 앱이 더한 언어 (기계 번역 사전) 도 Flutter 가 아는 언어면 그 언어로, 모르는 언어일 때만 영어로 (한국어가 섞이지 않게).
/// "zh-Hant" · "pt-BR" 같은 코드는 글자 체계 (4글자) · 나라 (2글자 · 숫자) 로 나눈다.
static Locale localeOf(String code) {
final parts = code.split(RegExp('[-_]'));
String? script;
String? country;
for (final s in parts.skip(1)) {
if (s.length == 4) {
script = s;
} else if (s.isNotEmpty) {
country = s;
}
}
final l = Locale.fromSubtags(languageCode: parts.first.toLowerCase(), scriptCode: script, countryCode: country?.toUpperCase());
return GlobalMaterialLocalizations.delegate.isSupported(l) ? l : const Locale('en');
}
/// Flutter 기본 글 사전들. Material 은 알고 Cupertino 는 모르는 언어 (예: ps) 에서도 Cupertino 글이 비지 않게 영어로 채운다.
static const localizationsDelegates = [...GlobalMaterialLocalizations.delegates, _CupertinoFallbackDelegate()];
/// Flutter 가 아는 모든 언어. 지금 언어를 맨 앞에 두어 글자 체계 · 나라까지 그대로 고르게 ("zh-Hant" 가 간체로 바뀌지 않게).
static List<Locale> supportedLocalesFor(Locale current) => [
current,
for (final c in kMaterialSupportedLanguages)
if (c != current.languageCode || current.scriptCode != null || current.countryCode != null) Locale(c),
];
@override
Widget build(BuildContext context) => _BrightnessHost(
c: controller,
// 화면 언어를 바꾸면 (i18n) Flutter 기본 글도 그 언어로
builder: (context) => ListenableBuilder(listenable: i18n, builder: (context, _) => _app(context)),
);
Widget _app(BuildContext context) {
final locale = localeOf(uiLanguage);
return MaterialApp(
locale: locale,
supportedLocales: supportedLocalesFor(locale),
localizationsDelegates: localizationsDelegates,
title: appTitle,
debugShowCheckedModeBanner: false,
theme: buildTheme(),
// 밝기 바꿈은 화면 전체를 다시 그려 바로 (전환 그림 없이 - JjColors 를 직접 읽는 곳과 같은 때 바뀌게)
themeAnimationDuration: Duration.zero,
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
navigatorObservers: [browserRouteObserver],
// 모든 화면에: 환경 설정 · 종료 버튼이 쓸 것 + 끌어다 놓은 동영상을 목록에 추가
builder: (context, child) => AppScope(
controller: controller,
onExit: onExit,
downloads: downloads,
bookmarks: bookmarks,
child: AppDropArea(c: controller, child: _SystemBarsArea(child: UiScaler(c: controller, child: child!)))),
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
);
}
}
/// X-plore 비교 5-1 ⑦: 환경 설정 "화면 밝기" · 기기 밝기를 따라 [JjColors] 를 바꾸고 화면 전체를 다시 그린다 (상태는 그대로)
class _BrightnessHost extends StatefulWidget {
final AppController c;
final WidgetBuilder builder;
const _BrightnessHost({required this.c, required this.builder});
@override
State<_BrightnessHost> createState() => _BrightnessHostState();
}
class _BrightnessHostState extends State<_BrightnessHost> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
widget.c.addListener(_check);
JjColors.use(_want());
}
@override
void dispose() {
widget.c.removeListener(_check);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Brightness _want() =>
resolveBrightness(widget.c.settings.display.brightness, WidgetsBinding.instance.platformDispatcher.platformBrightness);
@override
void didChangePlatformBrightness() => _check();
void _check() {
if (!mounted || !JjColors.use(_want())) return;
setState(() {});
// JjColors 를 직접 읽는 화면 조각도 새 색으로 (그 조각들은 Theme 이 바뀌어도 다시 그려지지 않는다)
WidgetsBinding.instance.addPostFrameCallback((_) {
final root = WidgetsBinding.instance.rootElement;
if (root != null) rebuildAllElements(root);
});
}
@override
Widget build(BuildContext context) => widget.builder(context);
}
/// Cupertino 글이 없는 언어만 영어로 (GlobalCupertinoLocalizations 뒤에 두어 그쪽이 아는 언어는 그쪽이 먼저)
class _CupertinoFallbackDelegate extends LocalizationsDelegate<CupertinoLocalizations> {
const _CupertinoFallbackDelegate();
@override
bool isSupported(Locale locale) => true;
@override
Future<CupertinoLocalizations> load(Locale locale) => DefaultCupertinoLocalizations.load(locale);
@override
bool shouldReload(_CupertinoFallbackDelegate old) => false;
}
/// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본)
/// 위쪽 버튼 줄이 시계 · 배터리 표시와 겹치지 않게 그만큼 띄운다. 전체 화면 재생 중에는 표시줄이 숨어 여백도 없다.
class _SystemBarsArea extends StatelessWidget {
final Widget child;
const _SystemBarsArea({required this.child});
@override
Widget build(BuildContext context) => Platform.isAndroid
? ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: SafeArea(child: child))
: child;
}

View File

@ -0,0 +1,201 @@
/// 약속: Android 시작 (창 하나 - 트레이 · 단축키 · 탐색기 연결 · 클립보드 감시는 없다). 공용 시작 단계 ([startCore]) 를 부르고
/// 자동 백업 판별 · 공용 폴더 · 화면 방향 · 권한 · 다른 앱에서 받은 파일 · 백그라운드 실행을 잇는다.
library;
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/model_backup.dart';
import '../app/install_marker.dart';
import '../app/net_guard.dart';
import '../app/tor_service.dart';
import '../ui/settings_problem.dart';
import '../ui/migration_notice.dart';
import '../ui/master_prompt.dart';
import '../ui/secret_issue.dart';
import '../app/settings.dart';
import '../platform/android/android_download_tools.dart';
import '../platform/android/android_keep_alive.dart';
import '../platform/android/android_shell.dart' show applyScreenOrientation;
import '../platform/android/android_storage.dart';
import '../ui/all_files_guide.dart';
import '../services/platform_services.dart';
import '../ui/app_actions.dart';
import '../ui/downloads_page.dart';
import '../ui/monitor_page.dart' show askLiveSyncStart;
import '../ui/update_dialog.dart';
import '../ui/version_restore.dart';
import '../ui/work_panel.dart';
import '../l10n/tr.dart';
import '../platform/android/android_updater.dart';
import '../ui/app_root.dart';
import 'startup_helpers.dart';
import 'app_start.dart';
/// Android: 창 하나. 트레이 · 단축키 · 탐색기 연결 · 다운로드 (yt-dlp · aria2) · 업데이트는 없다.
/// 화면 방향은 기본이 자동 (기기를 돌리는 대로). 환경 설정 > 화면 방향에서 가로 · 세로 고정.
Future<void> runAndroid(String dataDir) async {
HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭)
final services = PlatformServices.create();
final logs = Directory(p.join(dataDir, 'Logs'))..createSync(recursive: true);
final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log');
controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
// 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때)
final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync();
// 115: 다시 설치한 뒤 처음 켰는데 설정이 있으면 Android 자동 백업이 되살린 것 (표시 파일은 백업에서 뺐다)
final marker = InstallMarker(dataDir);
var autoBackup = false;
try {
final info = await PackageInfo.fromPlatform();
autoBackup = restoredByAutoBackup(
settingsExisted: !freshInstall, markerExists: marker.exists, installTime: info.installTime, updateTime: info.updateTime);
} catch (_) {}
marker.write();
if (autoBackup) controller.note(tr('Android 자동 백업에서 되살린 설정으로 시작합니다'));
String? sharedSnapshots;
try {
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot');
// l10n-skip: 실제 폴더 이름 (예전 보관을 찾아야 하므로 바꾸지 않음)
if (root != null) sharedSnapshots = p.join(root, 'Download', 'JJ_MKVMaker', '설정 보관');
if (root != null) ModelBackup.sharedRoot = p.join(root, 'Download', 'JJ_MKVMaker');
} catch (_) {}
// 공용 시작 단계 (Windows 와 같은 차례 - app_start). 화면 언어 다음에 화면 방향 · 앱 아이콘
final (:live, :bookmarks) = await startCore(controller, dataDir, sharedSnapshots: sharedSnapshots, afterLanguage: () async {
await applyScreenOrientation(controller.settings.display.orientation);
unawaited(services.shell.setAppIcon(controller.settings.display.appIcon));
});
final navigatorKey = GlobalKey<NavigatorState>();
final messengerKey = GlobalKey<ScaffoldMessengerState>();
AndroidStorageService.navigatorKey = navigatorKey;
attachMasterPrompt(navigatorKey);
// 다운로드 (앱 안 브라우저의 [다운로드] · 다운로드 목록): 앱에 넣은 yt-dlp · aria2c. 클립보드 감시는 하지 않는다.
AndroidDownloadTools.log = controller.note;
final downloads = createDownloads(controller, messengerKey: messengerKey, navigatorKey: navigatorKey);
watchDnsFailure(messengerKey, controller);
// 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)
AndroidKeepAlive(controller, downloads);
// 170: 업데이트 설치의 첫 시도 실패 원문을 작업 기록에
final updater = services.updater;
if (updater is AndroidUpdater) updater.log = controller.note;
// 백그라운드로 실행: 화면 (Activity) 을 닫아도 엔진은 살아 있다가 다시 열면 새 화면에 붙는다 → 화면 방향을 다시 적용
// (웹뷰는 텍스처로 그려 새 화면에 붙어도 그대로 보인다: browser_page _webSettings)
// 116: 권한 ("모든 파일에 대한 접근") 이 없어 분석에 실패한 동영상은 허용하고 돌아오면 저절로 다시 분석
controller.fileAccess = AndroidAccess.hasAllFiles;
var hadAccess = await AndroidAccess.hasAllFiles();
if (hadAccess) await allFilesGuideDue(controller, hasAccess: true);
AppLifecycleListener(onResume: () async {
await applyScreenOrientation(controller.settings.display.orientation);
final now = await AndroidAccess.hasAllFiles();
if (now && !hadAccess) await controller.reanalyzeFailed();
if (now) await allFilesGuideDue(controller, hasAccess: true);
hadAccess = now;
});
// 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
// 연결 프로그램 · 공유로 받은 동영상: 앱이 켜져 있을 때 (MainActivity.onNewIntent)
const android = MethodChannel('jj_mkvmaker/android');
android.setMethodCallHandler((call) async {
if (call.method == 'showJobs') {
// 작업 알림을 눌렀다: 작업 현황 화면으로
final nav = navigatorKey.currentState;
if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads));
return null;
}
if (call.method == 'openFiles') {
await openFromOtherApp(navigatorKey, controller, (call.arguments as List).cast<Map<Object?, Object?>>());
}
return null;
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
// 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터
try {
if (await android.invokeMethod<bool>('takeShowJobs') ?? false) {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads));
}
final opened = await android.invokeMethod<List<Object?>>('takeOpenedFiles') ?? const [];
if (opened.isNotEmpty) {
unawaited(openFromOtherApp(navigatorKey, controller, opened.cast<Map<Object?, Object?>>()));
return;
}
} catch (_) {}
// 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다.
// 39: 켤 때마다 띄우지 않고 한 번만. 그 뒤로는 파일 탐색기 위의 안내 · 동영상 항목의 안내가 알려 준다
// 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume)
final ctxA = navigatorKey.currentContext;
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
await showAllFilesGuide(ctxA, AndroidAccess.request, hasAccess: AndroidAccess.hasAllFiles);
}
// 124: "처음 시작 시" 면 마스터 비밀번호
final ctxM = navigatorKey.currentContext;
if (ctxM != null && ctxM.mounted) await askMasterAtStartup(ctxM, controller);
final home = AppNavButtons.homeId(controller.settings);
final nav0 = navigatorKey.currentState;
if (home != 'mkv' && nav0 != null) {
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
}
// 다시 켤 때 실시간 동기화를 골라서 시작
final ctx0 = navigatorKey.currentContext;
if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live);
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
final current = await currentAppVersion(services);
final ctxV = navigatorKey.currentContext;
if (ctxV != null && ctxV.mounted) await showSettingsProblem(ctxV, controller);
watchSecretIssue(messengerKey, controller);
if (ctxV != null && ctxV.mounted) await showMigrationNotice(ctxV, controller);
if (ctxV != null && ctxV.mounted) {
await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall, autoBackup: autoBackup,
restart: () async {
messengerKey.currentState?.showSnackBar(SnackBar(content: Text(tr('설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.'))));
await Future<void>.delayed(const Duration(seconds: 2));
await services.shell.quit();
});
}
// P0: 되돌리느라 공용 폴더에 옮겨 둔 받은 AI 모델 되살리기
final ctxModels = navigatorKey.currentContext;
if (ctxModels != null && ctxModels.mounted) await restoreModelBackup(ctxModels, controller);
// 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음)
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller));
});
runApp(JjMkvMakerApp(
controller: controller,
downloads: downloads,
bookmarks: bookmarks,
navigatorKey: navigatorKey,
messengerKey: messengerKey,
onExit: () async {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return;
if (controller.busy && ctx != null && ctx.mounted) {
final ok = await showDialog<bool>(
context: ctx,
builder: (c) => AlertDialog(
title: Text(tr('종료')),
content: Text(trf('진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?', [controller.currentJob ?? ''])),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(c, true), child: Text(tr('종료'))),
],
),
);
if (ok != true) return;
}
controller.cancel();
await downloads.shutdown();
await TorService.instance.stop();
controller.note(tr('── 정상 종료 ──'));
await services.shell.quit();
},
));
}

View File

@ -0,0 +1,80 @@
/// 약속: Windows · Android 가 같이 하는 시작 단계와 다운로드 연결.
/// [startCore] 는 저장소 위치 (컴포넌트 · AI · 버전별 설정 보관) → 설정 읽기 → DNS 강제 · Tor → 마스터 잠금 → rsync ·
/// 실시간 동기화 → 화면 언어 → 즐겨찾기 차례로 준비한다 (인터넷 · 저장된 비밀번호를 쓰는 것보다 먼저 네트워크 · 잠금).
/// [createDownloads] 는 다운로드 목록을 만들고 알림 · 다 받은 동영상을 MKV 목록에 넣기 · 설정 바꾸기를 잇는다.
library;
import 'package:flutter/material.dart';
import '../app/ai_local.dart';
import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart';
import '../app/component_store.dart';
import '../app/download_manager.dart';
import '../app/live_sync.dart';
import '../app/version_snapshot.dart';
import '../l10n/tr.dart';
import '../app/i18n_controller.dart';
import '../ui/added_to_mkv_notice.dart';
import '../ui/master_prompt.dart';
import 'startup_helpers.dart';
/// [startCore] 가 준비한 것
typedef StartedCore = ({LiveSync live, BookmarksController bookmarks});
/// [sharedSnapshots]: 버전별 설정 보관을 함께 둘 공용 폴더 (Android - 앱을 지웠다 다시 설치해도 남게).
/// [afterLanguage]: 화면 언어를 정한 바로 뒤 (즐겨찾기 전) 에 할 것 (Android: 화면 방향 · 앱 아이콘)
Future<StartedCore> startCore(AppController controller, String dataDir,
{String? sharedSnapshots, Future<void> Function()? afterLanguage}) async {
ComponentStore.dataDirectory = dataDir;
AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai)
AiStore.log = controller.note;
VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots);
await controller.init();
await setupNetwork(controller, dataDir); // DNS 강제 · Tor (인터넷을 쓰는 다른 것보다 먼저)
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.display.language, save: false);
await afterLanguage?.call();
final bookmarks = BookmarksController();
await bookmarks.load();
return (live: live, bookmarks: bookmarks);
}
DownloadManager createDownloads(AppController controller,
{required GlobalKey<ScaffoldMessengerState> messengerKey, required GlobalKey<NavigatorState> navigatorKey}) {
final services = controller.services;
final downloads = DownloadManager(
backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [],
settings: () => controller.settings,
);
// 클립보드 · 브라우저로 추가된 다운로드 알림
downloads.onAdded.listen((t) => messengerKey.currentState
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
// 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음)
downloads.onFinished.listen((t) async {
if (!controller.settings.download.addFinished) return;
final files = DownloadManager.videoFilesOf(t);
if (files.isEmpty) return;
final n = await controller.addDownloaded(files);
// MKV 만들기 목록으로 넘어갔으므로 다운로드 목록에서는 뺀다 (받은 파일은 그대로)
downloads.dropFromList(t);
// 줄이 사라진 이유와 [보기] (MKV 화면 · 그 동영상)
showAddedToMkvNotice(messengerKey, navigatorKey, controller, title: t.title, files: files, added: n);
});
downloads.setAutoAdd = (on) async {
await controller.updateSettings((x) => x.download.addFinished = on);
downloads.refresh();
};
downloads.useSystemDns = () async {
await controller.updateSettings((x) => x.network.mode = 'system');
downloads.refresh();
};
downloads.addToEditList = controller.addDownloaded;
downloads.log = controller.note;
return downloads;
}

View File

@ -0,0 +1,64 @@
/// 약속: 탐색기 메뉴 · 두 번째 실행 · 알림에서 온 요청 ([LaunchRequest]) 을 처리한다 (Windows).
/// 창을 보이고, 첫 화면이 뜰 때까지 (최대 10초) 기다린 뒤 - 재생 · AI 자막 · 실시간 동기화 화면 · 목록에 넣기 (설정에 따라 재생).
/// 어느 쪽이든 동영상은 MKV 화면의 목록에 들어간다.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../core/playlist.dart';
import '../ui/ai_dialog.dart';
import '../ui/player_page.dart' show playFiles;
import '../ui/monitor_page.dart' show MonitorPage;
Future<void> handleLaunchRequest(LaunchRequest req, AppController controller, GlobalKey<NavigatorState> navigatorKey) async {
await controller.services.shell.show();
// 99: 동기화가 멈췄다는 알림을 누름 → 실시간 동기화 화면
if (req.action == LaunchAction.lsync) {
for (var i = 0; i < 100 && navigatorKey.currentState == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final nav = navigatorKey.currentState;
if (nav != null) unawaited(nav.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: controller, initialTab: 1))));
return;
}
if (req.files.isEmpty) return;
// 프로그램을 파일과 함께 처음 켠 경우: 첫 화면이 뜰 때까지 (최대 10초) 기다린다
for (var i = 0; i < 100 && navigatorKey.currentContext == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
switch (req.action) {
case LaunchAction.play:
// 재생한 동영상도 MKV 화면의 목록에 넣는다
unawaited(controller.addVideos(req.files.where(isVideoFile).toList(), allowOutputFolder: true));
await playFiles(ctx, controller, req.files);
case LaunchAction.subtitle:
await controller.addVideos(req.files.where(isVideoFile).toList(), allowOutputFolder: true);
final targets = controller.videos.where((v) => req.files.any((f) => p.equals(f, v.path))).toList();
if (targets.isNotEmpty && ctx.mounted) {
controller.select(targets.first);
await showAiDialog(ctx, controller, targets.first, targets: targets);
}
case LaunchAction.lsync:
break; // 위에서 처리
case LaunchAction.add:
// 탐색기 더블클릭 · 연결 프로그램: 설정에 따라 재생 또는 편집 목록에 추가
final videos = req.files.where(isVideoFile).toList();
if (videos.isEmpty) return;
// 어느 쪽이든 MKV 화면의 목록에는 들어간다
final added = controller.addVideos(videos, allowOutputFolder: true);
if (controller.settings.playback.openFileAction == 'add') {
await added;
final first = controller.videos.where((v) => videos.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) controller.select(first);
} else {
unawaited(added);
await playFiles(ctx, controller, videos);
}
}
}

View File

@ -0,0 +1,50 @@
/// 약속: 탐색기에서 연 동영상의 새 재생 창 (Windows). 설정은 읽기만 하고 저장하지 않으며, 동영상 목록은 원래 창과 같은 파일로 맞춘다.
library;
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:window_manager/window_manager.dart';
import '../app/app_controller.dart';
import '../app/net_guard.dart';
import '../ui/master_prompt.dart';
import '../app/settings.dart';
import '../platform/windows/single_instance.dart';
import '../platform/windows/window_memory.dart';
import '../services/platform_services.dart';
import '../ui/player_page.dart';
import '../app/i18n_controller.dart';
import '../main.dart';
/// 탐색기에서 연 동영상의 새 창: 바로 재생하고, ← 로 나오면 같은 동영상 목록의 MKV 화면.
/// 트레이 · 단축키 · 다운로드 · 브라우저는 원래 창이 맡는다. 닫으면 이 창만 끝난다.
/// 동영상 목록은 원래 창과 같은 파일을 보므로 어느 창에서 넣거나 지워도 서로 맞춰진다.
/// 설정은 읽기만 한다 (원래 창의 설정 파일을 덮어쓰지 않도록 저장하지 않음).
Future<void> runSecondWindow(List<String> files, String dataDir) async {
HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭)
await windowManager.ensureInitialized();
await windowManager.setTitle(appTitle);
await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch();
final controller = AppController(PlatformServices.create());
await controller.init();
await setupNetwork(controller, dataDir, second: true);
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.display.language, save: false);
final store = SettingsStore();
controller.settings = await store.load();
final navigatorKey = GlobalKey<NavigatorState>();
// 124: 이 창도 저장된 비밀번호 (WebDAV 재생) 를 쓰기 전에 마스터를 묻는다 (풀린 상태는 창마다)
await setupMasterLock(controller, secrets: store.secrets);
attachMasterPrompt(navigatorKey);
runApp(JjMkvMakerApp(controller: controller, navigatorKey: navigatorKey, onExit: endProcessNow));
WidgetsBinding.instance.addPostFrameCallback((_) async {
await controller.shareVideoList(p.join(dataDir, 'videos.json'));
unawaited(controller.addVideos(files, allowOutputFolder: true));
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) await playFiles(ctx, controller, files);
});
}

View File

@ -0,0 +1,150 @@
/// 약속: 시작에 쓰는 도우미 - 다른 앱에서 연 파일 · DNS 강제 · Tor 준비 · DNS 실패 알림 · 새 재생 창 판단 · 지금 버전 · 마지막 작업 글.
library;
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/net_guard.dart';
import '../app/tor_service.dart';
import '../app/web_proxy.dart';
import '../app/settings.dart';
import '../core/playlist.dart';
import '../core/sftp.dart' show SftpRegistry;
import '../core/ftp.dart' show FtpRegistry;
import '../core/smb.dart' show SmbRemote;
import '../services/platform_services.dart';
import '../ui/network_settings.dart' show DnsFailureNotice;
import '../ui/player_page.dart';
import '../l10n/tr.dart';
import '../ui/notice.dart';
/// Android: 다른 앱 (파일 앱 "다음으로 열기" · 공유) 에서 받은 동영상.
/// 설정 "다른 앱에서 동영상을 열 때" 에 따라 바로 재생 (기본) 또는 MKV 목록에 추가. 실제 경로를 모르는 것은 재생만.
Future<void> openFromOtherApp(
GlobalKey<NavigatorState> navigatorKey, AppController c, List<Map<Object?, Object?>> files) async {
final paths = [for (final f in files) f['path'] as String?].whereType<String>().toList();
final uris = [
for (final f in files)
if (f['path'] == null) f['uri'] as String,
];
c.note(trf('다른 앱에서 열기: {0}', [files.map((f) => f['name'] ?? f['uri']).join(', ')]));
// MKV 화면으로 돌아온 뒤 (재생 중이었다면 그 화면은 닫는다)
navigatorKey.currentState?.popUntil((r) => r.isFirst);
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
if (paths.isNotEmpty) {
final added = c.addVideos(paths, allowOutputFolder: true);
if (c.settings.playback.openFileAction == 'add') {
await added;
final first = c.videos.where((v) => paths.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) c.select(first);
} else {
unawaited(added);
await playFiles(ctx, c, paths);
}
} else if (uris.isNotEmpty) {
// 파일 위치를 알려 주지 않는 앱: 재생만 (MKV 만들기 · 자막은 파일 고르기로 추가해야 함)
ScaffoldMessenger.maybeOf(ctx)?.showSnackBar(SnackBar(
content: Text(tr('파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 "동영상 추가" 로 고르세요.'))));
final create = c.services.createMediaPlayer;
if (create != null) {
await Navigator.of(ctx).push(MaterialPageRoute<void>(
builder: (_) => PlayerPage(c: c, player: create(), files: uris, start: 0)));
}
}
}
/// DNS 강제 (사용자 요청 10/10) · Tor 준비: 앱의 HTTP 가 정한 DNS 를 쓰게 하고, 설정이 바뀌면 다시 건다.
/// [second] (재생만 하는 새 창) 은 설정을 읽기만 한다
Future<void> setupNetwork(AppController c, String dataDir, {bool second = false}) async {
final g = NetGuard.instance
..config = (() => c.settings.network.config)
..log = c.note;
try {
await g.ensure();
} catch (e) {
c.note(trf('DNS 강제 프록시를 시작하지 못했습니다: {0}', [e]));
}
HttpOverrides.global = JjHttpOverrides();
// 88: SFTP 는 HTTP 가 아니라 직접 잇는다 - 같은 DNS 설정을 따르게 (집 안 이름 · 주소는 기기 DNS)
SftpRegistry.connect = SftpRegistry.connectWithDns(g.resolver);
FtpRegistry.resolve = FtpRegistry.resolveWithDns(g.resolver);
SmbRemote.resolve = FtpRegistry.resolveWithDns(g.resolver);
if (second) return;
TorService.dataDir = dataDir;
TorService.instance.log = c.note;
if (Platform.isAndroid) {
WebProxy.log = c.note;
// 환경 설정 "다른 화면으로 가면 소리 멈춤" 이 꺼져 있으면 일반 브라우저가 뒤에 있어도 DNS 문을 열어 둔다
WebProxy.keepPlayingWhenHidden = () => !c.settings.browser.pauseOnLeave;
try {
await WebProxy.apply();
} catch (e) {
c.note(trf('웹 뷰 프록시를 걸지 못했습니다: {0}', [e]));
}
// 앱이 뒤로 가면 웹 뷰 프록시 문을 닫고, 돌아오면 다시 (관리자 결정 10/11 - 열려 있는 시간을 줄인다)
AppLifecycleListener(
onResume: () => unawaited(WebProxy.setForeground(true)),
onHide: () => unawaited(WebProxy.setForeground(false)),
);
}
var last = c.settings.network.config.key;
var lastPause = c.settings.browser.pauseOnLeave;
c.addListener(() async {
if (Platform.isAndroid && c.settings.browser.pauseOnLeave != lastPause) {
lastPause = c.settings.browser.pauseOnLeave;
await WebProxy.apply();
}
final now = c.settings.network.config.key;
if (now == last) return;
last = now;
c.note(trf('DNS 설정: {0}', [c.settings.network.mode == 'system' ? tr('시스템 DNS') : c.settings.network.config.label]));
await g.ensure();
if (Platform.isAndroid) await WebProxy.apply();
});
}
/// DNS 서버에 닿지 않으면 알린다 (기기 DNS 로 몰래 바꾸지 않음). [시스템 DNS 로 바꾸기] 를 함께
void watchDnsFailure(GlobalKey<ScaffoldMessengerState> messengerKey, AppController c) {
NetGuard.instance.failure.addListener(() {
final f = NetGuard.instance.failure.value;
if (f == null) return;
// 248: 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열려 있으면 띄우지 않는다 (상태는 설정 · 작업 기록 줄에)
if (NetGuard.instance.noticeBlockers.value > 0) return;
showNotice(
messengerKey.currentState,
// 248: 버튼이 있는 안내라 사용자가 닫거나 누를 때까지 둔다 (한 번만 뜨고, 상태는 설정 · 작업 현황에 남음 - 249)
life: NoticeLife.untilClosed,
// 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError).
// 상태가 풀리거나 같은 안내를 보이는 화면이 열리면 스스로 닫힌다 (248)
content: DnsFailureNotice(failure: f),
action: (tr('시스템 DNS 로 바꾸기'), () => c.updateSettings((x) => x.network.mode = 'system')),
);
});
}
/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서")
bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) =>
s.playback.openFileWindow == 'new' &&
r.files.any(isVideoFile) &&
(r.action == LaunchAction.play || (r.action == LaunchAction.add && s.playback.openFileAction == 'play'));
/// 지금 실행 중인 버전 (예: 2026.10.05_003). 모르면 ''.
Future<String> currentAppVersion(PlatformServices services) async {
try {
return await services.updater?.currentVersion() ?? '';
} catch (_) {
return '';
}
}
/// 종료 창에 보일 "바로 전 작업": 하던 작업, 없으면 마지막 작업 기록 (시각 표시는 뺀다)
String lastWorkText(AppController c) {
if (c.currentJob != null) return c.currentJob!;
if (c.logs.isEmpty) return tr('없음');
return c.logs.last.replaceFirst(RegExp(r'^\[[\d:]+\]\s*'), '').split('\n').first;
}

View File

@ -0,0 +1,120 @@
/// 약속: Windows 의 종료 요청. [WindowsQuit.call] 이 true 를 돌려주면 프로그램을 끝낸다 (정리는 이미 마침).
/// - 창 ✕ · 종료 버튼: 설정 "종료를 누르면" 에 따라 백그라운드로 (창만 숨김) / 묻지 않고 종료 / 고르기
/// - 트레이 "종료" · 업데이트 설치 ([force]): 항상 종료 (받는 중인 다운로드 · 복사가 있으면 확인)
/// 끝낼 때는 작업 중지 → 다운로드 정리 → Tor 끄기 → 중복 실행 정리 → 작업이 멈출 때까지 → 목록 · 창 위치 저장 차례로.
library;
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/copy_center.dart';
import '../app/download_manager.dart';
import '../app/tor_service.dart';
import '../l10n/tr.dart';
import '../platform/windows/desktop_shell.dart';
import '../platform/windows/exit_trace.dart';
import '../platform/windows/session_marker.dart';
import '../platform/windows/single_instance.dart';
import '../platform/windows/window_memory.dart';
import '../ui/exit_dialog.dart';
import '../ui/exit_guard.dart' show confirmStopCopies;
import '../ui/downloads_page.dart' show confirmExit;
import 'startup_helpers.dart';
class WindowsQuit {
final AppController controller;
final DownloadManager downloads;
final GlobalKey<NavigatorState> navigatorKey;
final SingleInstance instance;
final WindowMemory memory;
final SessionMarker session;
WindowsQuit({
required this.controller,
required this.downloads,
required this.navigatorKey,
required this.instance,
required this.memory,
required this.session,
});
Future<bool> call({bool force = false}) async {
final shell = controller.services.shell;
final desktop = shell is DesktopShell ? shell : null;
var mode = force || (desktop?.closeFromTray ?? false) ? 'force' : controller.settings.run.closeAction;
// 트레이 아이콘이 없으면 숨긴 창을 끝낼 방법이 없다 → 고르게 한다
if (mode == 'background' && !(desktop?.hasTray ?? false)) mode = 'ask';
if (mode == 'ask') {
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return false;
final n = downloads.activeCount;
final copies = CopyCenter.peekOf(controller)?.activeCount ?? 0;
final running = [
if (controller.currentJob != null) controller.currentJob!,
if (n > 0) trf('다운로드 {0}개', [n]),
if (copies > 0) trf('복사 · 이동 {0}개', [copies]),
].join(' · ');
final pick = await showCloseChoice(ctx,
running: running.isEmpty ? tr('진행 중인 작업 없음') : running, hotkey: controller.settings.run.showHotkey);
if (pick == null) return false;
if (pick.$2) controller.updateSettings((x) => x.run.closeAction = pick.$1);
mode = pick.$1;
}
if (mode == 'background') {
await shell.hide();
return false;
}
if (mode == 'force') {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return false;
}
// 43: 복사 · 이동 중이면 (설정이 "묻지 않고 종료" 여도) 한 번 묻는다
{
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted && !await confirmStopCopies(ctx, controller)) return false;
}
ExitTrace.start();
final job = controller.currentJob;
final wait = controller.pendingJobs.length;
final steps = [
ExitStep(job == null ? tr('작업 확인 (진행 중인 작업 없음)') : trf('작업 중지: {0}{1}', [job, wait > 0 ? trf(' · 대기 {0}개', [wait]) : '']),
() async => controller.cancel()),
ExitStep(tr('다운로드 정리 (yt-dlp · aria2 종료)'), downloads.shutdown),
ExitStep(tr('Tor 끄기'), () => TorService.instance.stop()),
// 228: 다음 실행이 이 창에 넘기려 하지 않게 (instance.json 지우기 · 잠금 파일 놓기)
ExitStep(tr('중복 실행 막기 정리'), instance.close),
ExitStep(tr('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)'), () async {
while (controller.busy) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}),
ExitStep(tr('동영상 목록 · 창 위치 저장'), () async {
controller.stopVideoListShare();
await memory.save();
controller.note(tr('── 정상 종료 ──'));
session.markClean();
}),
ExitStep(tr('창 종료'), () async {}),
];
final ctx2 = navigatorKey.currentContext;
if (ctx2 != null && ctx2.mounted) {
await showExitProgress(ctx2, lastWork: lastWorkText(controller), steps: [
for (final s in steps)
ExitStep(s.label, () async {
try {
await s.run();
} finally {
ExitTrace.mark(trf('끝: {0}', [s.label]));
}
}),
]);
ExitTrace.mark(tr('종료 창 닫힘'));
} else {
for (final s in steps) {
try {
await s.run().timeout(const Duration(seconds: 8));
} catch (_) {}
}
}
return true;
}
}

173
lib/ui/app_root.dart Normal file
View File

@ -0,0 +1,173 @@
/// 약속: 앱의 뿌리 위젯 (MaterialApp - 화면 언어 · 밝기 · 시스템 막대 · 끌어다 놓기 · 화면 배율) 과 제목 글 [appTitle].
library;
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart' show CupertinoLocalizations, DefaultCupertinoLocalizations;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart';
import '../app/download_manager.dart';
import 'app_actions.dart';
import 'app_drop.dart';
import 'browser_page.dart';
import 'home_page.dart';
import 'theme.dart';
import '../l10n/tr.dart';
import '../app/i18n_controller.dart';
/// 제목 표시줄 글: "JJ_MKVMaker v1.2.3"
String appTitle = 'JJ_MKVMaker';
class JjMkvMakerApp extends StatelessWidget {
final AppController controller;
final DownloadManager? downloads;
final BookmarksController? bookmarks;
final GlobalKey<NavigatorState>? navigatorKey;
final GlobalKey<ScaffoldMessengerState>? messengerKey;
final VoidCallback? onExit;
const JjMkvMakerApp({
super.key,
required this.controller,
this.downloads,
this.bookmarks,
this.navigatorKey,
this.messengerKey,
this.onExit,
});
/// 140 · 158: Flutter 기본 글 (뒤로 · Licenses 등) 을 화면 언어로 - 앱 언어 코드 → Locale
/// 앱이 더한 언어 (기계 번역 사전) 도 Flutter 가 아는 언어면 그 언어로, 모르는 언어일 때만 영어로 (한국어가 섞이지 않게).
/// "zh-Hant" · "pt-BR" 같은 코드는 글자 체계 (4글자) · 나라 (2글자 · 숫자) 로 나눈다.
static Locale localeOf(String code) {
final parts = code.split(RegExp('[-_]'));
String? script;
String? country;
for (final s in parts.skip(1)) {
if (s.length == 4) {
script = s;
} else if (s.isNotEmpty) {
country = s;
}
}
final l = Locale.fromSubtags(languageCode: parts.first.toLowerCase(), scriptCode: script, countryCode: country?.toUpperCase());
return GlobalMaterialLocalizations.delegate.isSupported(l) ? l : const Locale('en');
}
/// Flutter 기본 글 사전들. Material 은 알고 Cupertino 는 모르는 언어 (예: ps) 에서도 Cupertino 글이 비지 않게 영어로 채운다.
static const localizationsDelegates = [...GlobalMaterialLocalizations.delegates, _CupertinoFallbackDelegate()];
/// Flutter 가 아는 모든 언어. 지금 언어를 맨 앞에 두어 글자 체계 · 나라까지 그대로 고르게 ("zh-Hant" 가 간체로 바뀌지 않게).
static List<Locale> supportedLocalesFor(Locale current) => [
current,
for (final c in kMaterialSupportedLanguages)
if (c != current.languageCode || current.scriptCode != null || current.countryCode != null) Locale(c),
];
@override
Widget build(BuildContext context) => _BrightnessHost(
c: controller,
// 화면 언어를 바꾸면 (i18n) Flutter 기본 글도 그 언어로
builder: (context) => ListenableBuilder(listenable: i18n, builder: (context, _) => _app(context)),
);
Widget _app(BuildContext context) {
final locale = localeOf(uiLanguage);
return MaterialApp(
locale: locale,
supportedLocales: supportedLocalesFor(locale),
localizationsDelegates: localizationsDelegates,
title: appTitle,
debugShowCheckedModeBanner: false,
theme: buildTheme(),
// 밝기 바꿈은 화면 전체를 다시 그려 바로 (전환 그림 없이 - JjColors 를 직접 읽는 곳과 같은 때 바뀌게)
themeAnimationDuration: Duration.zero,
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
navigatorObservers: [browserRouteObserver],
// 모든 화면에: 환경 설정 · 종료 버튼이 쓸 것 + 끌어다 놓은 동영상을 목록에 추가
builder: (context, child) => AppScope(
controller: controller,
onExit: onExit,
downloads: downloads,
bookmarks: bookmarks,
child: AppDropArea(c: controller, child: _SystemBarsArea(child: UiScaler(c: controller, child: child!)))),
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
);
}
}
/// X-plore 비교 5-1 ⑦: 환경 설정 "화면 밝기" · 기기 밝기를 따라 [JjColors] 를 바꾸고 화면 전체를 다시 그린다 (상태는 그대로)
class _BrightnessHost extends StatefulWidget {
final AppController c;
final WidgetBuilder builder;
const _BrightnessHost({required this.c, required this.builder});
@override
State<_BrightnessHost> createState() => _BrightnessHostState();
}
class _BrightnessHostState extends State<_BrightnessHost> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
widget.c.addListener(_check);
JjColors.use(_want());
}
@override
void dispose() {
widget.c.removeListener(_check);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Brightness _want() =>
resolveBrightness(widget.c.settings.display.brightness, WidgetsBinding.instance.platformDispatcher.platformBrightness);
@override
void didChangePlatformBrightness() => _check();
void _check() {
if (!mounted || !JjColors.use(_want())) return;
setState(() {});
// JjColors 를 직접 읽는 화면 조각도 새 색으로 (그 조각들은 Theme 이 바뀌어도 다시 그려지지 않는다)
WidgetsBinding.instance.addPostFrameCallback((_) {
final root = WidgetsBinding.instance.rootElement;
if (root != null) rebuildAllElements(root);
});
}
@override
Widget build(BuildContext context) => widget.builder(context);
}
/// Cupertino 글이 없는 언어만 영어로 (GlobalCupertinoLocalizations 뒤에 두어 그쪽이 아는 언어는 그쪽이 먼저)
class _CupertinoFallbackDelegate extends LocalizationsDelegate<CupertinoLocalizations> {
const _CupertinoFallbackDelegate();
@override
bool isSupported(Locale locale) => true;
@override
Future<CupertinoLocalizations> load(Locale locale) => DefaultCupertinoLocalizations.load(locale);
@override
bool shouldReload(_CupertinoFallbackDelegate old) => false;
}
/// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본)
/// 위쪽 버튼 줄이 시계 · 배터리 표시와 겹치지 않게 그만큼 띄운다. 전체 화면 재생 중에는 표시줄이 숨어 여백도 없다.
class _SystemBarsArea extends StatelessWidget {
final Widget child;
const _SystemBarsArea({required this.child});
@override
Widget build(BuildContext context) => Platform.isAndroid
? ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: SafeArea(child: child))
: child;
}

View File

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/startup/app_start.dart';
import 'package:jj_mkvmaker/startup/startup_helpers.dart';
/// main.dart 를 나눈 모듈의 약속 (Windows · Android 가 같이 쓰는 다운로드 연결)
void main() {
AppController ctl() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
test('약속 createDownloads: 다운로드 목록이 설정 · 편집 목록 · 작업 기록에 이어진다', () async {
final c = ctl();
final d = createDownloads(c, messengerKey: GlobalKey<ScaffoldMessengerState>(), navigatorKey: GlobalKey<NavigatorState>());
var refreshed = 0;
d.addListener(() => refreshed++);
await d.setAutoAdd!(false);
expect([c.settings.download.addFinished, refreshed], [false, 1], reason: '다 받은 것 넣기 끄기 → 설정 · 목록 다시 그림');
c.settings.network.mode = 'custom';
await d.useSystemDns!();
expect([c.settings.network.mode, refreshed], ['system', 2]);
d.log!('다운로드 기록');
expect(c.logs.last, endsWith('다운로드 기록'));
expect(await d.addToEditList!(const []), 0, reason: '편집 목록 넣기는 AppController.addDownloaded');
});
test('약속 lastWorkText: 하던 작업, 없으면 마지막 기록 (시각 뺌), 그것도 없으면 "없음"', () {
final c = ctl();
expect(lastWorkText(c), '없음');
c.note('MKV 만들기 끝\n자세히');
expect(lastWorkText(c), 'MKV 만들기 끝');
c.currentJob = 'AI 자막: a.mp4';
expect(lastWorkText(c), 'AI 자막: a.mp4');
});
}