diff --git a/README.md b/README.md index 9a4d085..cc2b802 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ | **자막 번역** | 이미 있는 자막(예: 영어)을 원하는 언어로 번역해 MKV 에 추가. 인터넷에서 받은 자막도 한국어로 함께 번역 | | **인터넷 자막 찾기** | OpenSubtitles.com 검색 (영상 해시로 "이 파일용" 자막 우선) | | **동영상 플레이어** | 재생 목록, 전체 화면, 팝업(항상 위), 원본의 0.5·1·2·4배, 자막·음성 트랙 선택, 속도 | -| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기 | +| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) | +| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 | | **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 | | **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 | | **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 | diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 2a4891b..f91b5c9 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; @@ -172,11 +174,12 @@ class AppController extends ChangeNotifier { await addVideos(paths); } - Future addVideos(List paths) async { + /// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다 + Future addVideos(List paths, {bool allowOutputFolder = false}) async { for (final path in paths) { if (videos.any((v) => p.equals(v.path, path))) continue; - // jj_mkv 출력 폴더 안의 파일은 제외 - if (p.basename(p.dirname(path)) == outputFolderName) { + // jj_mkv 출력 폴더 안의 파일은 제외 (폴더째 넣을 때 만든 결과물이 다시 들어가지 않게) + if (!allowOutputFolder && p.basename(p.dirname(path)) == outputFolderName) { _log('건너뜀 (출력 폴더의 파일): ${p.basename(path)}'); continue; } @@ -186,6 +189,77 @@ class AppController extends ChangeNotifier { notifyListeners(); await _loadVideo(v); } + _saveVideoList(); + } + + // ───────── 동영상 목록 저장 · 창끼리 공유 ───────── + // + // 목록(경로)을 파일 하나에 적어 둔다. 다시 켜면 그대로 불러오고, 창이 여러 개면 + // 1초마다 파일을 보고 다른 창에서 넣거나 지운 것을 따라 한다. + + String? _videoListFile; + String _videoListSaved = '[]'; + Timer? _videoListTimer; + bool _videoListSyncing = false; + + /// 목록 저장 · 공유 시작 ([file] 을 먼저 불러온다) + Future shareVideoList(String file) async { + _videoListFile = file; + await syncVideoList(); + _videoListTimer?.cancel(); + _videoListTimer = Timer.periodic(const Duration(seconds: 1), (_) => syncVideoList()); + } + + void stopVideoListShare() { + _videoListTimer?.cancel(); + _videoListTimer = null; + } + + void _saveVideoList() { + final file = _videoListFile; + if (file == null || _videoListSyncing) return; + final text = jsonEncode([for (final v in videos) v.path]); + if (text == _videoListSaved) return; + _videoListSaved = text; + try { + // 다른 창이 읽는 중에 반쯤 쓴 파일을 보지 않도록: 임시 파일 → 이름 바꾸기 + final tmp = File('$file.$pid.tmp')..writeAsStringSync(text, flush: true); + tmp.renameSync(file); + } catch (_) {} + } + + /// 파일이 바뀌었으면 (다른 창에서 넣거나 지움) 이 창의 목록을 맞춘다 + Future syncVideoList() async { + final file = _videoListFile; + if (file == null || _videoListSyncing) return; + String text; + try { + final f = File(file); + if (!f.existsSync()) return; + text = f.readAsStringSync(); + } catch (_) { + return; + } + if (text == _videoListSaved) return; + List want; + try { + want = (jsonDecode(text) as List).cast(); + } catch (_) { + return; + } + _videoListSyncing = true; + try { + _videoListSaved = text; + bool inWant(VideoItem v) => want.any((w) => p.equals(w, v.path)); + // 작업 중인 동영상은 지우지 않는다 + videos.removeWhere((v) => !inWant(v) && v.status != JobStatus.running); + if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull; + notifyListeners(); + await addVideos([for (final w in want) if (File(w).existsSync()) w], allowOutputFolder: true); + } finally { + _videoListSyncing = false; + _saveVideoList(); // 없는 파일을 뺐거나 맞추는 동안 이 창에서 바꾼 것이 있으면 다시 적는다 + } } Future _loadVideo(VideoItem v) async { @@ -237,6 +311,7 @@ class AppController extends ChangeNotifier { if (v.status == JobStatus.running) return; videos.remove(v); if (selected == v) selected = videos.isEmpty ? null : videos.first; + _saveVideoList(); notifyListeners(); } @@ -244,6 +319,7 @@ class AppController extends ChangeNotifier { if (busy) return; videos.clear(); selected = null; + _saveVideoList(); notifyListeners(); } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index dec3268..b66fb84 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -22,6 +22,10 @@ class AppSettings { /// 최소화하면 작업 표시줄 대신 트레이로 bool minimizeToTray = true; + /// 종료 (창 X · 종료 버튼) 를 눌렀을 때: + /// 'background' 창만 숨기고 다운로드 · 변환 계속 / 'quit' 묻지 않고 모두 종료 / 'ask' 매번 고르기 + String closeAction = 'background'; + /// 창 보이기 단축키 (예: Ctrl+Shift+X) String showHotkey = 'Ctrl+Shift+X'; @@ -58,6 +62,12 @@ class AppSettings { /// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl]) String startScreen = 'home'; + + /// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가 + String openFileAction = 'play'; + + /// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창 + String openFileWindow = 'same'; String homeUrl = 'https://www.youtube.com/'; /// 앱 안 브라우저 엔진: 'edge' (WebView2, 기본). 'chrome' 은 내려받아 연결 (준비 중) @@ -97,6 +107,7 @@ class AppSettings { 'downloadRoot': downloadRoot, 'clipboardWatch': clipboardWatch, 'minimizeToTray': minimizeToTray, + 'closeAction': closeAction, 'showHotkey': showHotkey, 'askAiOptions': askAiOptions, 'addFinishedDownloads': addFinishedDownloads, @@ -112,6 +123,8 @@ class AppSettings { 'maxParallelJobs': maxParallelJobs, 'showLog': showLog, 'startScreen': startScreen, + 'openFileAction': openFileAction, + 'openFileWindow': openFileWindow, 'homeUrl': homeUrl, 'browserEngine': browserEngine, 'externalBrowser': externalBrowser, @@ -139,6 +152,7 @@ class AppSettings { ..downloadRoot = j['downloadRoot'] as String? ..clipboardWatch = j['clipboardWatch'] as bool? ?? true ..minimizeToTray = j['minimizeToTray'] as bool? ?? true + ..closeAction = const ['background', 'quit', 'ask'].contains(j['closeAction']) ? j['closeAction'] as String : 'background' ..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X' ..askAiOptions = j['askAiOptions'] as bool? ?? true ..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true @@ -154,6 +168,8 @@ class AppSettings { ..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5 ..showLog = j['showLog'] as bool? ?? true ..startScreen = j['startScreen'] as String? ?? 'home' + ..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play' + ..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same' ..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/' ..browserEngine = j['browserEngine'] as String? ?? 'edge' ..externalBrowser = j['externalBrowser'] as String? ?? 'system' diff --git a/lib/main.dart b/lib/main.dart index de2760c..b3a5a4e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,30 +2,52 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.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/bookmarks_controller.dart'; import 'app/download_manager.dart'; import 'app/settings.dart'; import 'core/playlist.dart'; +import 'platform/windows/desktop_shell.dart'; import 'platform/windows/single_instance.dart'; +import 'platform/windows/window_memory.dart'; import 'services/platform_services.dart'; import 'ui/ai_dialog.dart'; import 'ui/browser_page.dart'; import 'ui/downloads_page.dart'; +import 'ui/exit_dialog.dart'; import 'ui/home_page.dart'; import 'ui/player_page.dart'; import 'ui/setup_dialog.dart'; import 'ui/update_dialog.dart'; import 'ui/theme.dart'; +/// 제목 표시줄 글: "JJ_MKVMaker v1.2.3" +String appTitle = 'JJ_MKVMaker'; + +/// 버전을 읽어 제목을 정하고, 설정 폴더를 돌려준다 +Future _prepare() async { + try { + appTitle = 'JJ_MKVMaker v${(await PackageInfo.fromPlatform()).version}'; + } catch (_) {} + return (await getApplicationSupportDirectory()).path; +} + Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + final dataDir = await _prepare(); // 이미 실행 중이면 인수(탐색기에서 고른 파일)를 넘기고 끝낸다 + final request = LaunchRequest.parse(args); + final newWindow = wantsNewPlayerWindow(request, await SettingsStore().load()); final instance = SingleInstance(); - if (!await instance.claim(LaunchRequest.parse(args), waitForExit: args.contains('--restart'))) { + if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) { + // 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다 + if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir); exit(0); } @@ -43,11 +65,66 @@ Future main(List args) async { final navigatorKey = GlobalKey(); final messengerKey = GlobalKey(); - /// 종료 요청 (창 X · 트레이 종료 · 종료 버튼): 다운로드 중이면 확인 - Future confirmQuit() async { - final ctx = navigatorKey.currentContext; - if (ctx != null && !await confirmExit(ctx, downloads)) return false; - await downloads.shutdown(); + 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.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) '다운로드 $n개', + ].join(' · ').ifEmptyText('진행 중인 작업 없음'), + hotkey: controller.settings.showHotkey); + if (pick == null) return false; + if (pick.$2) controller.updateSettings((x) => x.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; + } + final job = controller.currentJob; + final wait = controller.pendingJobs.length; + final steps = [ + ExitStep(job == null ? '작업 확인 (진행 중인 작업 없음)' : '작업 중지: $job${wait > 0 ? ' · 대기 $wait개' : ''}', + () async => controller.cancel()), + ExitStep('다운로드 정리 (yt-dlp · aria2 종료)', downloads.shutdown), + ExitStep('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)', () async { + while (controller.busy) { + await Future.delayed(const Duration(milliseconds: 100)); + } + }), + ExitStep('동영상 목록 · 창 위치 저장', () async { + controller.stopVideoListShare(); + await memory.save(); + }), + ExitStep('창 종료', () => Future.delayed(const Duration(milliseconds: 200))), + ]; + final ctx2 = navigatorKey.currentContext; + if (ctx2 != null && ctx2.mounted) { + await showExitProgress(ctx2, lastWork: lastWorkText(controller), steps: steps); + } else { + for (final s in steps) { + try { + await s.run().timeout(const Duration(seconds: 8)); + } catch (_) {} + } + } return true; } @@ -58,6 +135,12 @@ Future main(List args) async { onDownloadsRequested: () => navigatorKey.currentState ?.push(MaterialPageRoute(builder: (_) => DownloadsPage(d: downloads))), ); + // 제목 표시줄에 버전 · 지난번 창 위치에서 열기 · 동영상 목록 불러오기 (창끼리 공유) + await windowManager.setTitle(appTitle); + final shell = services.shell; + if (shell is DesktopShell) memory.paused = () => shell.isPopup; + await memory.restoreAndWatch(); + unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); // 클립보드로 추가된 다운로드 알림 · 트레이 글 downloads.onAdded.listen((t) => messengerKey.currentState @@ -79,11 +162,17 @@ Future main(List args) async { // 탐색기 메뉴 · 두 번째 실행에서 온 요청 처리 instance.listen((req) async { await services.shell.show(); + 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 || req.files.isEmpty) return; - if (!ctx.mounted) return; + 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()); @@ -95,11 +184,23 @@ Future main(List args) async { await showAiDialog(ctx, controller, targets.first, targets: targets); } case LaunchAction.add: - await controller.addVideos(req.files.where(isVideoFile).toList()); + // 탐색기 더블클릭 · 연결 프로그램: 설정에 따라 재생 또는 편집 목록에 추가 + final videos = req.files.where(isVideoFile).toList(); + if (videos.isEmpty) return; + // 어느 쪽이든 MKV 화면의 목록에는 들어간다 + final added = controller.addVideos(videos, allowOutputFolder: true); + if (controller.settings.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); + } } }); - controller.confirmQuit = confirmQuit; + controller.confirmQuit = () => confirmQuit(force: true); // 업데이트 설치: 항상 종료 // 처음 화면이 뜬 뒤: 필수 프로그램 점검 (없으면 물어보고 설치) → 새 버전 확인 (하루 한 번) WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -127,6 +228,44 @@ Future main(List args) async { )); } +/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서") +bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) => + s.openFileWindow == 'new' && + r.files.any(isVideoFile) && + (r.action == LaunchAction.play || (r.action == LaunchAction.add && s.openFileAction == 'play')); + +extension on String { + String ifEmptyText(String other) => isEmpty ? other : this; +} + +/// 종료 창에 보일 "바로 전 작업": 하던 작업, 없으면 마지막 작업 기록 (시각 표시는 뺀다) +String lastWorkText(AppController c) { + if (c.currentJob != null) return c.currentJob!; + if (c.logs.isEmpty) return '없음'; + return c.logs.last.replaceFirst(RegExp(r'^\[[\d:]+\]\s*'), '').split('\n').first; +} + +/// 탐색기에서 연 동영상의 새 창: 바로 재생하고, ← 로 나오면 같은 동영상 목록의 MKV 화면. +/// 트레이 · 단축키 · 다운로드 · 브라우저는 원래 창이 맡는다. 닫으면 이 창만 끝난다. +/// 동영상 목록은 원래 창과 같은 파일을 보므로 어느 창에서 넣거나 지워도 서로 맞춰진다. +/// 설정은 읽기만 한다 (원래 창의 설정 파일을 덮어쓰지 않도록 저장하지 않음). +Future runSecondWindow(List files, String dataDir) async { + await windowManager.ensureInitialized(); + await windowManager.setTitle(appTitle); + await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch(); + final controller = AppController(PlatformServices.create()); + await controller.init(); + controller.settings = await SettingsStore().load(); + final navigatorKey = GlobalKey(); + runApp(JjCapCutApp(controller: controller, navigatorKey: navigatorKey)); + 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 JjCapCutApp extends StatelessWidget { final AppController controller; final DownloadManager? downloads; @@ -147,7 +286,7 @@ class JjCapCutApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - title: 'JJ_MKVMaker', + title: appTitle, debugShowCheckedModeBanner: false, theme: buildTheme(), navigatorKey: navigatorKey, diff --git a/lib/platform/windows/desktop_shell.dart b/lib/platform/windows/desktop_shell.dart index ca5cac7..fb4fd7b 100644 --- a/lib/platform/windows/desktop_shell.dart +++ b/lib/platform/windows/desktop_shell.dart @@ -62,7 +62,7 @@ class DesktopShell with WindowListener implements AppShell { _onDownloads?.call(); }); menu.addSeparator(); - item('종료', _requestClose); + item('종료', () => _requestClose(fromTray: true)); t.setContextMenu(menu); t.setContextMenuTrigger(tray.ContextMenuTrigger.rightClicked); t.addListener((e) { @@ -149,14 +149,23 @@ class DesktopShell with WindowListener implements AppShell { @override Future setTooltip(String text) async => _tray?.setTooltip(text); - Future _requestClose() async { + /// 트레이 아이콘이 있는지 (없으면 창을 숨긴 뒤 끝낼 방법이 없으므로 백그라운드로 보내지 않는다) + bool get hasTray => _tray != null; + + /// 지금 처리 중인 종료 요청이 트레이 메뉴 "종료" 에서 왔는지 (항상 완전히 종료) + bool closeFromTray = false; + + Future _requestClose({bool fromTray = false}) async { if (_closing) return; _closing = true; + closeFromTray = fromTray; try { - await show(); + // 트레이에서 종료: 진행 창을 보여 주려고 창을 연다. 창의 ✕ 는 이미 보이는 상태. + if (fromTray) await show(); if (await _onClose()) await quit(); } finally { _closing = false; + closeFromTray = false; } } @@ -173,6 +182,9 @@ class DesktopShell with WindowListener implements AppShell { Rect? _beforePopup; bool _popup = false; + /// 팝업 보기 중 (이때의 작은 창 크기는 기억하지 않는다) + bool get isPopup => _popup; + @override Future setFullScreen(bool on) async { if (on && _popup) await setPopup(false); diff --git a/lib/platform/windows/single_instance.dart b/lib/platform/windows/single_instance.dart index 861b15b..83eb280 100644 --- a/lib/platform/windows/single_instance.dart +++ b/lib/platform/windows/single_instance.dart @@ -22,7 +22,8 @@ class SingleInstance { /// 첫 번째 실행이면 true (요청을 받을 준비). 이미 실행 중이면 인수를 넘기고 false. /// [waitForExit]: 재시작 - 기존 창이 닫힐 때까지 (최대 15초) 기다렸다가 첫 번째가 된다. - Future claim(LaunchRequest initial, {bool waitForExit = false}) async { + /// [forward] 가 false 면 이미 실행 중이어도 인수를 넘기지 않는다 (새 재생 창으로 따로 뜰 때). + Future claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async { for (var i = 0;; i++) { try { _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, port); @@ -32,6 +33,7 @@ class SingleInstance { await Future.delayed(const Duration(milliseconds: 250)); continue; } + if (!forward) return false; if (await _forward(initial)) return false; return true; // 다른 프로그램이 포트를 쓰는 경우: 넘기지 못했으므로 그냥 실행 } diff --git a/lib/platform/windows/window_memory.dart b/lib/platform/windows/window_memory.dart new file mode 100644 index 0000000..6a2dab0 --- /dev/null +++ b/lib/platform/windows/window_memory.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/painting.dart'; +import 'package:screen_retriever/screen_retriever.dart'; +import 'package:window_manager/window_manager.dart'; + +/// 창 위치 · 크기 · 최대화 여부를 기억했다가 다음에 같은 자리에서 연다. +/// +/// [key] 별로 따로 기억한다 ('main' = 원래 창, 'second' = 탐색기에서 연 새 창). +/// 전체 화면 · 팝업 보기 · 최소화 상태에서는 저장하지 않는다 (그 전의 보통 크기를 유지). +class WindowMemory with WindowListener { + final String file; + final String key; + Timer? _debounce; + + /// 팝업 보기처럼 창 크기를 잠깐 바꾸는 동안 true 로 두면 저장하지 않는다 + bool Function()? paused; + + WindowMemory(this.file, this.key); + + Map _readAll() { + try { + return jsonDecode(File(file).readAsStringSync()) as Map; + } catch (_) { + return {}; + } + } + + /// 저장된 자리로 옮기고, 이후 움직이거나 크기를 바꾸면 자동 저장 + Future restoreAndWatch() async { + try { + final m = _readAll()[key]; + if (m is Map) { + final r = Rect.fromLTWH((m['x'] as num).toDouble(), (m['y'] as num).toDouble(), + (m['w'] as num).toDouble(), (m['h'] as num).toDouble()); + // 모니터를 뺐거나 해상도가 바뀌어 화면 밖이면 옮기지 않는다 + if (r.width >= 400 && r.height >= 300 && await _visible(r)) { + await windowManager.setBounds(r); + if (m['max'] == true) await windowManager.maximize(); + } + } + } catch (_) {} + windowManager.addListener(this); + } + + static Future _visible(Rect r) async { + for (final d in await screenRetriever.getAllDisplays()) { + final area = (d.visiblePosition ?? Offset.zero) & (d.visibleSize ?? d.size); + final hit = area.intersect(r); + if (hit.width >= 200 && hit.height >= 100) return true; + } + return false; + } + + Future save() async { + try { + if (paused?.call() ?? false) return; + if (await windowManager.isFullScreen() || await windowManager.isMinimized()) return; + if (!await windowManager.isVisible()) return; + final max = await windowManager.isMaximized(); + final all = _readAll(); + final old = all[key]; + if (max && old is Map) { + // 최대화: 보통 크기는 그대로 두고 표시만 + all[key] = {...old, 'max': true}; + } else { + final b = await windowManager.getBounds(); + all[key] = {'x': b.left, 'y': b.top, 'w': b.width, 'h': b.height, 'max': max}; + } + final tmp = File('$file.$pid.tmp')..writeAsStringSync(jsonEncode(all), flush: true); + tmp.renameSync(file); + } catch (_) {} + } + + void _later() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), save); + } + + @override + void onWindowMoved() => _later(); + @override + void onWindowResized() => _later(); + @override + void onWindowMaximize() => _later(); + @override + void onWindowUnmaximize() => _later(); +} diff --git a/lib/ui/exit_dialog.dart b/lib/ui/exit_dialog.dart new file mode 100644 index 0000000..98693b9 --- /dev/null +++ b/lib/ui/exit_dialog.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// 종료할 때 하는 일 한 가지 +class ExitStep { + final String label; + final Future Function() run; + const ExitStep(this.label, this.run); +} + +/// 종료를 눌렀을 때 고르기: 'quit' 모두 종료 / 'background' 백그라운드로 / null 취소. +/// 두 번째 값이 true 면 "다음부터 묻지 않기" (고른 것을 설정에 저장). +Future<(String, bool)?> showCloseChoice(BuildContext context, {required String running, required String hotkey}) => + showDialog<(String, bool)>(context: context, builder: (_) => _CloseChoice(running: running, hotkey: hotkey)); + +class _CloseChoice extends StatefulWidget { + final String running, hotkey; + const _CloseChoice({required this.running, required this.hotkey}); + + @override + State<_CloseChoice> createState() => _CloseChoiceState(); +} + +class _CloseChoiceState extends State<_CloseChoice> { + bool _remember = false; + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('종료할까요?'), + content: SizedBox( + width: 440, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('지금: ${widget.running}', style: const TextStyle(fontSize: 13)), + const SizedBox(height: 10), + Text('· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 (${widget.hotkey} 또는 트레이 아이콘으로 다시 열기)\n' + + '· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다', + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + InkWell( + onTap: () => setState(() => _remember = !_remember), + child: Row(children: [ + Checkbox(value: _remember, onChanged: (v) => setState(() => _remember = v ?? false)), + const Flexible( + child: Text('다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)', style: TextStyle(fontSize: 12))), + ]), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')), + OutlinedButton( + onPressed: () => Navigator.pop(context, ('quit', _remember)), child: const Text('모두 종료')), + FilledButton( + autofocus: true, + onPressed: () => Navigator.pop(context, ('background', _remember)), + child: const Text('백그라운드로')), + ], + ); +} + +/// 종료 진행 창: "종료 중입니다." → 바로 전 작업 → 단계별 진행 → "Have You Good Time" +/// +/// 각 단계는 실패해도 (또는 [stepTimeout] 을 넘겨도) 다음으로 넘어간다 - 종료가 멈추면 안 되므로. +Future showExitProgress( + BuildContext context, { + required String lastWork, + required List steps, + Duration stepTimeout = const Duration(seconds: 8), + Duration farewell = const Duration(milliseconds: 1000), +}) => + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ExitProgress(lastWork: lastWork, steps: steps, stepTimeout: stepTimeout, farewell: farewell), + ); + +class _ExitProgress extends StatefulWidget { + final String lastWork; + final List steps; + final Duration stepTimeout, farewell; + const _ExitProgress( + {required this.lastWork, required this.steps, required this.stepTimeout, required this.farewell}); + + @override + State<_ExitProgress> createState() => _ExitProgressState(); +} + +class _ExitProgressState extends State<_ExitProgress> { + int _done = 0; + bool _bye = false; + + @override + void initState() { + super.initState(); + _run(); + } + + Future _run() async { + // 첫 화면 ("종료 중입니다.") 이 보인 뒤 시작 + await Future.delayed(const Duration(milliseconds: 150)); + for (final s in widget.steps) { + // 너무 빨리 지나가 읽을 수 없지 않도록 단계마다 잠깐은 보여 준다 + final shown = Future.delayed(const Duration(milliseconds: 150)); + try { + await s.run().timeout(widget.stepTimeout); + } catch (_) {} + await shown; + if (!mounted) return; + setState(() => _done++); + } + setState(() => _bye = true); + await Future.delayed(widget.farewell); + if (mounted) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: false, + child: AlertDialog( + title: Text(_bye ? 'Have You Good Time' : '종료 중입니다.'), + content: SizedBox( + width: 420, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('바로 전 작업: ${widget.lastWork}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 12), + for (final (i, s) in widget.steps.indexed) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row(children: [ + SizedBox( + width: 18, + height: 18, + child: i < _done + ? const Icon(Icons.check_circle, size: 18, color: JjColors.success) + : i == _done + ? const CircularProgressIndicator(strokeWidth: 2) + : const Icon(Icons.circle_outlined, size: 18, color: JjColors.textDim), + ), + const SizedBox(width: 10), + Expanded( + child: Text(s.label, + style: TextStyle(fontSize: 13, color: i <= _done ? JjColors.text : JjColors.textDim)), + ), + ]), + ), + ]), + ), + ), + ); +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 09a6f0e..67dfb97 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -346,6 +346,30 @@ class _SettingsPageState extends State { onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), ), ), + ListTile( + title: const Text('탐색기에서 동영상을 열 때'), + subtitle: const Text('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)'), + trailing: DropdownButton( + value: s.openFileAction, + items: const [ + DropdownMenuItem(value: 'play', child: Text('바로 재생 (기본)')), + DropdownMenuItem(value: 'add', child: Text('편집 목록에 추가 (MKV 만들기 대상)')), + ], + onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!), + ), + ), + ListTile( + title: const Text('탐색기에서 연 동영상을 재생할 창'), + subtitle: const Text('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다'), + trailing: DropdownButton( + value: s.openFileWindow, + items: const [ + DropdownMenuItem(value: 'same', child: Text('켜져 있는 창에서 (기본)')), + DropdownMenuItem(value: 'new', child: Text('새 창에서')), + ], + onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!), + ), + ), _ExternalPlayers(c: c), _ContextMenuTile(c: c), _section('인터넷 자막 (OpenSubtitles.com)'), @@ -438,6 +462,22 @@ class _SettingsPageState extends State { ), ), _section('실행'), + ListTile( + title: const Text('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면'), + subtitle: Text('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. ${s.showHotkey} 또는 트레이 아이콘으로 다시 엽니다.\n' + + '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)'), + isThreeLine: true, + trailing: DropdownButton( + value: s.closeAction, + items: const [ + DropdownMenuItem(value: 'background', child: Text('백그라운드로 계속 (기본)')), + DropdownMenuItem(value: 'quit', child: Text('다운로드 · 변환도 모두 종료 (묻지 않음)')), + DropdownMenuItem(value: 'ask', child: Text('매번 고르기')), + ], + onChanged: (v) => c.updateSettings((x) => x.closeAction = v!), + ), + ), SwitchListTile( value: s.minimizeToTray, onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v), diff --git a/pubspec.yaml b/pubspec.yaml index 667eb52..8f1b8fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.0+4 +version: 1.1.1+5 environment: sdk: ^3.13.4 diff --git a/test/player_test.dart b/test/player_test.dart index 8803598..3336177 100644 --- a/test/player_test.dart +++ b/test/player_test.dart @@ -6,13 +6,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/playlist.dart'; +import 'package:jj_mkvmaker/main.dart' show lastWorkText, wantsNewPlayerWindow; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/platform/windows/shell_integration.dart'; import 'package:jj_mkvmaker/platform/windows/single_instance.dart'; import 'package:jj_mkvmaker/services/media_player.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/exit_dialog.dart'; import 'package:jj_mkvmaker/ui/player_page.dart'; import 'package:path/path.dart' as p; @@ -137,6 +140,25 @@ void main() { expect(LaunchRequest.parse([r'D:\a.mp4']).action, LaunchAction.add); expect(LaunchRequest.parse(['--subtitle', 'x']).toArgs(), ['--subtitle', 'x']); }); + + test('탐색기에서 연 동영상: 새 재생 창 여부', () { + final open = LaunchRequest.parse([r'D:.mkv']); + final play = LaunchRequest.parse(['--play', r'D:.mkv']); + final sub = LaunchRequest.parse(['--subtitle', r'D:.mkv']); + final s = AppSettings(); + expect([s.openFileAction, s.openFileWindow], ['play', 'same']); + expect(wantsNewPlayerWindow(open, s), isFalse); // 기본: 켜져 있는 창에서 + s.openFileWindow = 'new'; + expect(wantsNewPlayerWindow(open, s), isTrue); + expect(wantsNewPlayerWindow(play, s), isTrue); + expect(wantsNewPlayerWindow(sub, s), isFalse); // 자막 만들기는 원래 창 + expect(wantsNewPlayerWindow(LaunchRequest.parse(const []), s), isFalse); + s.openFileAction = 'add'; + expect(wantsNewPlayerWindow(open, s), isFalse); // 목록에 추가는 원래 창 + expect(wantsNewPlayerWindow(play, s), isTrue); + final back = AppSettings.fromJson(s.toJson()); + expect([back.openFileAction, back.openFileWindow], ['add', 'new']); + }); }); test('중복 실행 방지: 나중 실행은 넘기고, 짧은 시간의 요청은 하나로', () async { @@ -230,4 +252,123 @@ void main() { await tester.pumpWidget(const SizedBox()); expect(pl.calls.last, 'dispose'); }); + + test('동영상 목록: 저장 → 다시 켜면 그대로, 창 두 개가 서로 맞춤 (추가 · 삭제)', () async { + final dir = Directory.systemTemp.createTempSync('jj_list_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final list = p.join(dir.path, 'videos.json'); + Directory(p.join(dir.path, 'jj_mkv')).createSync(); + final a = p.join(dir.path, 'a.mp4'), b = p.join(dir.path, 'jj_mkv', 'b.mkv'); + for (final f in [a, b]) { + File(f).writeAsStringSync('x'); + } + AppController make() => + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + + final w1 = make(); + await w1.shareVideoList(list); + await w1.addVideos([a]); + // 탐색기에서 직접 연 파일은 jj_mkv 안이어도 들어간다 (폴더째 넣을 때는 제외) + await w1.addVideos([b]); + expect(w1.videos, hasLength(1)); + await w1.addVideos([b], allowOutputFolder: true); + expect(w1.videos.map((v) => v.fileName), ['a.mp4', 'b.mkv']); + + // 새 창: 같은 목록으로 시작 + final w2 = make(); + await w2.shareVideoList(list); + expect(w2.videos.map((v) => v.fileName), ['a.mp4', 'b.mkv']); + + // 새 창에서 삭제 → 원래 창에서도 삭제 + w2.removeVideo(w2.videos.first); + await w1.syncVideoList(); + expect(w1.videos.map((v) => v.fileName), ['b.mkv']); + // 원래 창에서 추가 → 새 창에도 + await w1.addVideos([a]); + await w2.syncVideoList(); + expect(w2.videos.map((v) => v.fileName), ['b.mkv', 'a.mp4']); + + // 없어진 파일은 다시 켤 때 빠진다 + File(a).deleteSync(); + final w3 = make(); + await w3.shareVideoList(list); + expect(w3.videos.map((v) => v.fileName), ['b.mkv']); + for (final w in [w1, w2, w3]) { + w.stopVideoListShare(); + } + }); + + testWidgets('종료 진행 창: 종료 중입니다 → 단계 → Have You Good Time', (tester) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + expect(lastWorkText(c), '없음'); + final ran = []; + final gate = Completer(); + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { + ctx = x; + return const SizedBox(); + }))); + var closed = false; + unawaited(showExitProgress(ctx, lastWork: 'MKV 만들기', steps: [ + ExitStep('다운로드 정리', () async => ran.add('dl')), + ExitStep('쓰레드 확인', () async { + await gate.future; + ran.add('thread'); + }), + ExitStep('실패해도 계속', () async => throw StateError('x')), + ExitStep('창 종료', () async => ran.add('close')), + ]).then((_) => closed = true)); + await tester.pump(); + expect(find.text('종료 중입니다.'), findsOneWidget); + expect(find.text('바로 전 작업: MKV 만들기'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + expect(ran, ['dl']); // 쓰레드 확인에서 기다리는 중 + expect(find.byType(CircularProgressIndicator), findsOneWidget); + gate.complete(); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(ran, ['dl', 'thread', 'close']); + expect(find.text('Have You Good Time'), findsOneWidget); + expect(closed, isFalse); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pump(); + expect(closed, isTrue); + }); + + testWidgets('종료를 누르면: 설정 기본값 · 고르기 창 (백그라운드로 / 모두 종료 / 묻지 않기)', (tester) async { + expect(AppSettings().closeAction, 'background'); + expect(AppSettings.fromJson((AppSettings()..closeAction = 'quit').toJson()).closeAction, 'quit'); + expect(AppSettings.fromJson({'closeAction': 'x'}).closeAction, 'background'); + + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { + ctx = x; + return const SizedBox(); + }))); + Future<(String, bool)?> open() => showCloseChoice(ctx, running: '다운로드 2개', hotkey: 'Ctrl+Shift+X'); + + var r = open(); + await tester.pumpAndSettle(); + expect(find.text('지금: 다운로드 2개'), findsOneWidget); + expect(find.textContaining('Ctrl+Shift+X'), findsOneWidget); + await tester.tap(find.text('백그라운드로')); + await tester.pumpAndSettle(); + expect(await r, ('background', false)); + + r = open(); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + await tester.tap(find.text('모두 종료')); + await tester.pumpAndSettle(); + expect(await r, ('quit', true)); + + r = open(); + await tester.pumpAndSettle(); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(await r, isNull); + }); }