diff --git a/lib/main.dart b/lib/main.dart index e3aa90b..946c115 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _prepare() async { @@ -139,121 +114,24 @@ Future main(List 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(); final messengerKey = GlobalKey(); + final downloads = createDownloads(controller, messengerKey: messengerKey, navigatorKey: navigatorKey); attachMasterPrompt(navigatorKey); final memory = WindowMemory(p.join(dataDir, 'window.json'), 'main'); - /// 종료 요청. true 를 돌려주면 프로그램을 끝낸다. - /// - 창 ✕ · 종료 버튼: 설정 "종료를 누르면" 에 따라 백그라운드로 (창만 숨김) / 묻지 않고 종료 / 고르기 - /// - 트레이 "종료" · 업데이트 설치 ([force]): 항상 종료 (받는 중인 다운로드가 있으면 확인) - Future 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.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 main(List 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 main(List 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 main(List 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.delayed(const Duration(milliseconds: 100)); - } - final nav = navigatorKey.currentState; - if (nav != null) unawaited(nav.push(MaterialPageRoute(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.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 main(List 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 main(List args) async { )); } -/// Android: 다른 앱 (파일 앱 "다음으로 열기" · 공유) 에서 받은 동영상. -/// 설정 "다른 앱에서 동영상을 열 때" 에 따라 바로 재생 (기본) 또는 MKV 목록에 추가. 실제 경로를 모르는 것은 재생만. -Future openFromOtherApp( - GlobalKey navigatorKey, AppController c, List> files) async { - final paths = [for (final f in files) f['path'] as String?].whereType().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( - builder: (_) => PlayerPage(c: c, player: create(), files: uris, start: 0))); - } - } -} - -/// DNS 강제 (사용자 요청 10/10) · Tor 준비: 앱의 HTTP 가 정한 DNS 를 쓰게 하고, 설정이 바뀌면 다시 건다. -/// [second] (재생만 하는 새 창) 은 설정을 읽기만 한다 -Future 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 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 _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 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('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(); - final messengerKey = GlobalKey(); - 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>()); - } - return null; - }); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - // 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터 - try { - if (await android.invokeMethod('takeShowJobs') ?? false) { - final nav = navigatorKey.currentState; - if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads)); - } - final opened = await android.invokeMethod>('takeOpenedFiles') ?? const []; - if (opened.isNotEmpty) { - unawaited(openFromOtherApp(navigatorKey, controller, opened.cast>())); - 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.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( - 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 runSecondWindow(List 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(); - // 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? navigatorKey; - final GlobalKey? 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 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 { - const _CupertinoFallbackDelegate(); - - @override - bool isSupported(Locale locale) => true; - - @override - Future 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; -} diff --git a/lib/startup/android_start.dart b/lib/startup/android_start.dart new file mode 100644 index 0000000..58c7025 --- /dev/null +++ b/lib/startup/android_start.dart @@ -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 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('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(); + final messengerKey = GlobalKey(); + 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>()); + } + return null; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + // 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터 + try { + if (await android.invokeMethod('takeShowJobs') ?? false) { + final nav = navigatorKey.currentState; + if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads)); + } + final opened = await android.invokeMethod>('takeOpenedFiles') ?? const []; + if (opened.isNotEmpty) { + unawaited(openFromOtherApp(navigatorKey, controller, opened.cast>())); + 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.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( + 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(); + }, + )); +} diff --git a/lib/startup/app_start.dart b/lib/startup/app_start.dart new file mode 100644 index 0000000..9b08c26 --- /dev/null +++ b/lib/startup/app_start.dart @@ -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 startCore(AppController controller, String dataDir, + {String? sharedSnapshots, Future 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 messengerKey, required GlobalKey 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; +} diff --git a/lib/startup/launch_requests.dart b/lib/startup/launch_requests.dart new file mode 100644 index 0000000..5fe7de0 --- /dev/null +++ b/lib/startup/launch_requests.dart @@ -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 handleLaunchRequest(LaunchRequest req, AppController controller, GlobalKey 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.delayed(const Duration(milliseconds: 100)); + } + final nav = navigatorKey.currentState; + if (nav != null) unawaited(nav.push(MaterialPageRoute(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.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); + } + } +} diff --git a/lib/startup/second_window.dart b/lib/startup/second_window.dart new file mode 100644 index 0000000..a47fa1d --- /dev/null +++ b/lib/startup/second_window.dart @@ -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 runSecondWindow(List 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(); + // 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); + }); +} diff --git a/lib/startup/startup_helpers.dart b/lib/startup/startup_helpers.dart new file mode 100644 index 0000000..06210b6 --- /dev/null +++ b/lib/startup/startup_helpers.dart @@ -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 openFromOtherApp( + GlobalKey navigatorKey, AppController c, List> files) async { + final paths = [for (final f in files) f['path'] as String?].whereType().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( + builder: (_) => PlayerPage(c: c, player: create(), files: uris, start: 0))); + } + } +} + +/// DNS 강제 (사용자 요청 10/10) · Tor 준비: 앱의 HTTP 가 정한 DNS 를 쓰게 하고, 설정이 바뀌면 다시 건다. +/// [second] (재생만 하는 새 창) 은 설정을 읽기만 한다 +Future 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 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 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; +} diff --git a/lib/startup/windows_quit.dart b/lib/startup/windows_quit.dart new file mode 100644 index 0000000..fc279cf --- /dev/null +++ b/lib/startup/windows_quit.dart @@ -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 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 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.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; + } +} diff --git a/lib/ui/app_root.dart b/lib/ui/app_root.dart new file mode 100644 index 0000000..a025b3b --- /dev/null +++ b/lib/ui/app_root.dart @@ -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? navigatorKey; + final GlobalKey? 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 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 { + const _CupertinoFallbackDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future 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; +} diff --git a/test/startup_modules_contract_test.dart b/test/startup_modules_contract_test.dart new file mode 100644 index 0000000..0ec98fc --- /dev/null +++ b/test/startup_modules_contract_test.dart @@ -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(), navigatorKey: GlobalKey()); + 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'); + }); +}