From 996dd44cb53247d24b2377158d244d7b78ef0ae3 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:28:00 +0900 Subject: [PATCH] =?UTF-8?q?v2026.09.30=5F002:=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=81=8C=EC=96=B4=EB=8B=A4=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0,=20=ED=99=94=EB=A9=B4=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88,=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20CO?= =?UTF-8?q?M=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 끌어다 놓기를 앱 전체에서 받아 동영상 목록에 추가 (안내 테두리 · 결과 알림) - 화면 크기 조절 버튼 (70~160%) 과 기본 화면 크기 설정 - MKV 화면: 폴더 열기 버튼, 동영상 목록 오른쪽 클릭 메뉴 - 위쪽 막대가 창 너비에 맞춰 줄어듦 (설정 · 종료 버튼이 밀려나지 않게) - WebView2 환경을 만들기 전에 COM 을 다시 준비, 실패 결과를 붙들지 않음, 3초마다 COM 상태 확인 (풀려 있으면 다시 준비하고 작업 기록에 남김) Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- integration_test/com_guard_test.dart | 51 ++++ lib/app/app_controller.dart | 3 + lib/app/settings.dart | 12 + lib/main.dart | 15 +- lib/platform/windows/com_guard.dart | 42 +++ lib/platform/windows/desktop_shell.dart | 14 + lib/services/app_shell.dart | 5 + lib/ui/app_actions.dart | 94 +++++- lib/ui/app_drop.dart | 89 ++++++ lib/ui/browser_page.dart | 24 +- lib/ui/home_page.dart | 293 +++++++++++-------- lib/ui/settings_page.dart | 24 ++ lib/ui/work_panel.dart | 365 ++++++++++++++++-------- pubspec.yaml | 2 +- test/drop_test.dart | 106 +++++++ test/scale_menu_test.dart | 168 +++++++++++ 17 files changed, 1063 insertions(+), 247 deletions(-) create mode 100644 integration_test/com_guard_test.dart create mode 100644 lib/platform/windows/com_guard.dart create mode 100644 lib/ui/app_drop.dart create mode 100644 test/drop_test.dart create mode 100644 test/scale_menu_test.dart diff --git a/README.md b/README.md index a4d97b5..dbb255a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ | **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 | | **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 | | **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] | -| **상태 표시** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 환경 설정 · 종료 버튼 | +| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 | +| **끌어다 놓기 · 오른쪽 클릭** | 어느 화면에서든 동영상 · 폴더를 놓으면 목록에 추가. 목록에서 오른쪽 클릭: 폴더 열기 · 재생 · 자막 만들기 · 자막 찾기 · 삭제 | ## 실행 diff --git a/integration_test/com_guard_test.dart b/integration_test/com_guard_test.dart new file mode 100644 index 0000000..4517322 --- /dev/null +++ b/integration_test/com_guard_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/platform/windows/com_guard.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; + +/// 실제 Edge(WebView2): 다른 구성 요소가 COM 준비 상태를 풀어 버린 상황을 만들고 +/// (브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨던 문제) 그래도 브라우저 환경이 만들어지는지 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('COM 이 풀려 있어도 브라우저 환경을 만든다', (tester) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + + // 1. 화면을 그리는 스레드 = 실행 파일이 COM 을 준비한 스레드인지 (그래야 여기서 다시 준비한 것이 통한다) + final first = ComGuard.ensure(); + // ignore: avoid_print + print('RESULT 처음 확인: $first (1 = 이미 준비되어 있음 → 같은 스레드)'); + expect(first, 1); + ComGuard.releaseForTest(); // 방금 올린 만큼 내림 + + // 2. 준비 상태를 끝까지 푼다 (문제 상황). 여러 구성 요소가 겹쳐 준비해 두므로 넉넉히 여러 번. + for (var i = 0; i < 8; i++) { + ComGuard.releaseForTest(); + } + final dir = Directory.systemTemp.createTempSync('jj_com_').path; + final raw = await tester.runAsync(() async { + try { + await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw')); + return null; + } catch (e) { + return e; + } + }); + // ignore: avoid_print + print('RESULT 풀린 상태에서 그냥 만들기: ${raw ?? '성공'}'); + expect('$raw', contains('Cannot create WebViewEnvironment')); + + // 3. 앱의 방식 (만들기 전에 다시 준비) 으로는 만들어진다 + for (var i = 0; i < 8; i++) { + ComGuard.releaseForTest(); + } + final env = await tester.runAsync(() => browserEnvironment(dir)); + // ignore: avoid_print + print('RESULT 앱 방식으로 만들기: ${env == null ? '실패' : '성공'}'); + expect(env, isNotNull); + }, timeout: const Timeout(Duration(minutes: 2))); +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 2150bd0..e72bac3 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1100,6 +1100,9 @@ class AppController extends ChangeNotifier { notifyListeners(); } + /// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때) + void note(String msg) => _log(msg); + void _log(String msg) { final t = DateTime.now(); String two(int n) => n.toString().padLeft(2, '0'); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index b66fb84..946c361 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -63,6 +63,14 @@ class AppSettings { /// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl]) String startScreen = 'home'; + /// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈), + /// [uiScaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감) + double uiScale = 1.0; + double uiScaleDefault = 1.0; + static const double uiScaleMin = 0.7, uiScaleMax = 1.6; + static double clampUiScale(num? v) => + ((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20; + /// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가 String openFileAction = 'play'; @@ -123,6 +131,8 @@ class AppSettings { 'maxParallelJobs': maxParallelJobs, 'showLog': showLog, 'startScreen': startScreen, + 'uiScale': uiScale, + 'uiScaleDefault': uiScaleDefault, 'openFileAction': openFileAction, 'openFileWindow': openFileWindow, 'homeUrl': homeUrl, @@ -168,6 +178,8 @@ class AppSettings { ..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5 ..showLog = j['showLog'] as bool? ?? true ..startScreen = j['startScreen'] as String? ?? 'home' + ..uiScale = clampUiScale(j['uiScale'] as num?) + ..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?) ..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play' ..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same' ..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/' diff --git a/lib/main.dart b/lib/main.dart index 6f53197..5518485 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'app/download_manager.dart'; import 'app/settings.dart'; import 'core/app_update.dart'; import 'core/playlist.dart'; +import 'platform/windows/com_guard.dart'; import 'platform/windows/desktop_shell.dart'; import 'platform/windows/exit_trace.dart'; import 'platform/windows/single_instance.dart'; @@ -20,6 +21,7 @@ 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'; @@ -181,6 +183,13 @@ Future main(List args) async { await controller.updateSettings((x) => x.addFinishedDownloads = on); downloads.refresh(); }; + // COM 준비 상태 지킴이: 풀려 있으면 (앱 안 브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨는 원인) + // 다시 준비하고 작업 기록에 남긴다. 바로 앞의 기록을 보면 무엇을 한 뒤에 풀렸는지 알 수 있다. + Timer.periodic(const Duration(seconds: 3), (_) { + if (ComGuard.repairIfNeeded()) { + controller.note('⚠ Windows COM 준비 상태가 풀려 있어 다시 준비했습니다 (브라우저 · 끌어다 놓기에 필요)'); + } + }); downloads.addToEditList = controller.addDownloaded; unawaited(downloads.startClipboardWatch()); @@ -316,7 +325,11 @@ class JjCapCutApp extends StatelessWidget { theme: buildTheme(), navigatorKey: navigatorKey, scaffoldMessengerKey: messengerKey, - builder: (context, child) => AppScope(controller: controller, onExit: onExit, child: child!), + // 모든 화면에: 환경 설정 · 종료 버튼이 쓸 것 + 끌어다 놓은 동영상을 목록에 추가 + builder: (context, child) => AppScope( + controller: controller, + onExit: onExit, + child: AppDropArea(c: controller, child: UiScaler(c: controller, child: child!))), home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks), ); } diff --git a/lib/platform/windows/com_guard.dart b/lib/platform/windows/com_guard.dart new file mode 100644 index 0000000..bacc81b --- /dev/null +++ b/lib/platform/windows/com_guard.dart @@ -0,0 +1,42 @@ +import 'dart:ffi'; +import 'dart:io'; + +typedef _CoInitC = Int32 Function(Pointer reserved, Uint32 mode); +typedef _CoInitD = int Function(Pointer reserved, int mode); +typedef _CoUninitC = Void Function(); +typedef _CoUninitD = void Function(); + +/// Windows COM 준비 상태를 다루는 도우미. +/// +/// 앱 안 브라우저 (Edge WebView2) 는 화면을 그리는 스레드에 COM 이 준비되어 있어야 만들어진다. +/// 실행 파일이 시작할 때 준비해 두지만, 다른 구성 요소가 그것을 풀어 버리면 +/// "CoInitialize 가 호출되지 않았습니다" 로 브라우저가 뜨지 않는다 → 만들기 직전에 다시 준비한다. +class ComGuard { + static final DynamicLibrary _ole = DynamicLibrary.open('ole32.dll'); + static final _CoInitD _init = _ole.lookupFunction<_CoInitC, _CoInitD>('CoInitializeEx'); + static final _CoUninitD _uninit = _ole.lookupFunction<_CoUninitC, _CoUninitD>('CoUninitialize'); + + static const _apartmentThreaded = 0x2; + + /// 이 스레드에 COM 을 준비한다 (이미 되어 있으면 그대로). 돌려주는 값은 Windows 결과 코드: + /// 0 = 새로 준비함 (풀려 있었음), 1 = 이미 준비되어 있었음, 음수 = 다른 방식으로 이미 준비됨 · 실패 + static int ensure() { + if (!Platform.isWindows) return 1; + try { + return _init(nullptr, _apartmentThreaded); + } catch (_) { + return -1; + } + } + + /// 준비 상태를 확인하고, 풀려 있었으면 다시 준비한다. 다시 준비했으면 true. + /// (이미 준비되어 있었으면 확인하느라 올린 만큼 도로 내려 원래대로 둔다) + static bool repairIfNeeded() { + final hr = ensure(); + if (hr == 1) _uninit(); + return hr == 0; + } + + /// 시험용: 준비 상태를 한 단계 푼다 + static void releaseForTest() => _uninit(); +} diff --git a/lib/platform/windows/desktop_shell.dart b/lib/platform/windows/desktop_shell.dart index 5e7d743..8b5c212 100644 --- a/lib/platform/windows/desktop_shell.dart +++ b/lib/platform/windows/desktop_shell.dart @@ -268,6 +268,20 @@ class DesktopShell with WindowListener implements AppShell { @override Future openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser); + /// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더). + @override + Future revealFile(String path) async { + if (File(path).existsSync()) { + await Process.start('explorer.exe', ['/select,', path], mode: ProcessStartMode.detached); + return; + } + var dir = Directory(path).existsSync() ? Directory(path) : File(path).parent; + while (!dir.existsSync() && dir.parent.path != dir.path) { + dir = dir.parent; + } + await Process.start('explorer.exe', [dir.path], mode: ProcessStartMode.detached); + } + @override List installedBrowsers() => [ for (final b in browserExecutables.keys) diff --git a/lib/services/app_shell.dart b/lib/services/app_shell.dart index 21d3a1d..9093a63 100644 --- a/lib/services/app_shell.dart +++ b/lib/services/app_shell.dart @@ -51,6 +51,9 @@ abstract class AppShell { /// 웹 주소를 외부 브라우저로 열기. [browser]: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale' Future openUrl(String url, {String browser = 'system'}); + /// 파일이 있는 폴더를 파일 관리자(탐색기)로 열고 그 파일을 선택해 보여 준다 + Future revealFile(String path); + /// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중) List installedBrowsers(); @@ -105,6 +108,8 @@ class NoopShell implements AppShell { @override Future openUrl(String url, {String browser = 'system'}) async {} @override + Future revealFile(String path) async {} + @override List installedBrowsers() => const []; @override Future> missingTools() async => const []; diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index fa1328c..5a9db9b 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; +import '../app/settings.dart'; import '../services/system_usage.dart'; import 'settings_page.dart'; import 'theme.dart'; @@ -86,6 +87,90 @@ class UsageView extends StatelessWidget { ); } +/// 화면 크기 조절: [−] [100%] [+]. 10% 씩 바꾸고, 가운데 숫자를 누르면 환경 설정의 기본 크기로. +class ScaleButtons extends StatelessWidget { + final AppController c; + const ScaleButtons({super.key, required this.c}); + + void _set(double v) => c.updateSettings((s) => s.uiScale = AppSettings.clampUiScale(v)); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: c, + builder: (context, _) { + final s = c.settings.uiScale; + Widget btn(IconData icon, String tip, VoidCallback? f) => SizedBox( + width: 26, + height: 32, + child: IconButton( + tooltip: tip, + padding: EdgeInsets.zero, + iconSize: 16, + icon: Icon(icon), + onPressed: f, + ), + ); + return Padding( + padding: const EdgeInsets.only(left: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + btn(Icons.remove, '화면 작게', s <= AppSettings.uiScaleMin + 0.001 ? null : () => _set(s - 0.1)), + Tooltip( + message: '기본 크기 (${(c.settings.uiScaleDefault * 100).round()}%) 로 · 기본 크기는 환경 설정 > 화면 에서', + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _set(c.settings.uiScaleDefault), + child: SizedBox( + width: 40, + height: 28, + child: Center( + child: Text('${(s * 100).round()}%', + style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', fontWeight: FontWeight.w600)), + ), + ), + ), + ), + btn(Icons.add, '화면 크게', s >= AppSettings.uiScaleMax - 0.001 ? null : () => _set(s + 0.1)), + ]), + ); + }, + ); +} + +/// 화면 전체를 [AppSettings.uiScale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치). +/// 앱의 builder 에서 한 번 감싼다 - 대화 상자 · 메뉴 · 안내 글까지 모두 같은 배율. +class UiScaler extends StatelessWidget { + final AppController c; + final Widget child; + const UiScaler({super.key, required this.c, required this.child}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: c, + builder: (context, _) { + final s = c.settings.uiScale; + if ((s - 1).abs() < 0.001) return child; + return LayoutBuilder(builder: (context, box) { + final size = box.biggest / s; + final mq = MediaQuery.of(context); + return ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: size.width, + maxWidth: size.width, + minHeight: size.height, + maxHeight: size.height, + child: Transform.scale( + scale: s, + alignment: Alignment.topLeft, + child: MediaQuery(data: mq.copyWith(size: size), child: child), + ), + ), + ); + }); + }, + ); +} + /// 환경 설정 · 종료 버튼. 모든 화면의 위쪽 막대 맨 오른쪽에 같은 크기로 놓는다. class AppActions extends StatelessWidget { /// 직접 주지 않으면 [AppScope] 의 것을 쓴다 @@ -95,7 +180,10 @@ class AppActions extends StatelessWidget { /// 환경 설정 화면 자신: 설정 버튼을 "지금 여기" 로 표시하고 누를 수 없게 final bool onSettingsPage; - const AppActions({super.key, this.c, this.onExit, this.onSettingsPage = false}); + /// 창이 좁을 때 CPU · MEM · DISK 를 숨긴다 (버튼 자리는 그대로) + final bool showUsage; + + const AppActions({super.key, this.c, this.onExit, this.onSettingsPage = false, this.showUsage = true}); @override Widget build(BuildContext context) { @@ -106,7 +194,9 @@ class AppActions extends StatelessWidget { final usage = controller?.usage; return Row(mainAxisSize: MainAxisSize.min, children: [ // CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준) - if (usage != null) UsageView(usage: usage), + if (usage != null && showUsage) UsageView(usage: usage), + // 화면 크기: − 100% + + if (controller != null) ScaleButtons(c: controller), const SizedBox(width: 4), if (controller != null) IconButton( diff --git a/lib/ui/app_drop.dart b/lib/ui/app_drop.dart new file mode 100644 index 0000000..d814d14 --- /dev/null +++ b/lib/ui/app_drop.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../core/playlist.dart'; +import 'theme.dart'; + +/// 끌어다 놓은 파일 · 폴더에서 동영상을 골라 MKV 만들기의 동영상 목록에 넣는다. +/// 직접 놓은 동영상 파일은 jj_mkv 폴더 안에 있어도 넣고, 폴더째 놓으면 그 안의 jj_ 출력 폴더는 건너뛴다. +/// 돌려주는 값: (새로 넣은 개수, 찾은 동영상 개수) +Future<(int, int)> addDroppedVideos(AppController c, List paths) async { + final direct = [ + for (final path in paths) + if (isVideoFile(path) && !await FileSystemEntity.isDirectory(path)) path, + ]; + final found = await collectVideos(paths, + isDirectory: (x) => FileSystemEntity.isDirectory(x), + listDir: (x) async => Directory(x).list().map((e) => e.path).toList()); + final all = {...direct, ...found}.toList(); + final before = c.videos.length; + await c.addVideos(direct, allowOutputFolder: true); + await c.addVideos(found); + final added = c.videos.skip(before).toList(); + if (added.isNotEmpty) c.select(added.first); + return (added.length, all.length); +} + +/// 앱 전체를 감싸는 끌어다 놓기 영역: 어느 화면에서 놓아도 MKV 만들기의 동영상 목록에 추가한다. +/// 끌고 들어오면 화면에 안내를 띄우고, 놓으면 결과를 알려 준다. +class AppDropArea extends StatefulWidget { + final AppController c; + final Widget child; + const AppDropArea({super.key, required this.c, required this.child}); + + @override + State createState() => _AppDropAreaState(); +} + +class _AppDropAreaState extends State { + bool _over = false; + + Future _dropped(List paths) async { + setState(() => _over = false); + final m = ScaffoldMessenger.maybeOf(context); + final (added, found) = await addDroppedVideos(widget.c, paths); + m?.clearSnackBars(); + m?.showSnackBar(SnackBar( + content: Text(found == 0 + ? '끌어다 놓은 항목에 동영상이 없습니다.' + : added == 0 + ? '이미 동영상 목록에 있습니다 ($found개).' + : 'MKV 만들기의 동영상 목록에 $added개를 추가했습니다.' + '${added < found ? ' (${found - added}개는 이미 있음)' : ''}'))); + } + + @override + Widget build(BuildContext context) => DropTarget( + onDragEntered: (_) => setState(() => _over = true), + onDragExited: (_) => setState(() => _over = false), + onDragDone: (d) => _dropped(d.files.map((f) => f.path).toList()), + child: Stack(children: [ + widget.child, + if (_over) + Positioned.fill( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: JjColors.accent.withValues(alpha: 0.12), + border: Border.all(color: JjColors.accent, width: 3), + ), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + decoration: BoxDecoration(color: JjColors.panel, borderRadius: BorderRadius.circular(10)), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.playlist_add, color: JjColors.accent), + SizedBox(width: 10), + Text('여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다', + style: TextStyle(fontSize: 15, color: JjColors.text, decoration: TextDecoration.none)), + ]), + ), + ), + ), + ), + ]), + ); +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 4819049..e1694a1 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -10,6 +10,7 @@ import '../app/download_manager.dart'; import '../core/bookmarks.dart'; import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies; import '../core/web_address.dart'; +import '../platform/windows/com_guard.dart'; import 'app_actions.dart'; import 'downloads_page.dart'; import 'theme.dart'; @@ -341,9 +342,26 @@ Future? _env; /// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (한 번만 만들어 재사용) Future browserEnvironment(String dir) => - _env ??= Platform.isWindows - ? WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir)) - : Future.value(null); + _env ??= Platform.isWindows ? _createEnvironment(dir) : Future.value(null); + +Future _createEnvironment(String dir) async { + Future create() => + WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir)); + try { + // COM 이 풀려 있으면 "CoInitialize 가 호출되지 않았습니다" 로 실패한다 → 만들기 전에 다시 준비 + ComGuard.ensure(); + try { + return await create(); + } catch (_) { + ComGuard.ensure(); + return await create(); + } + } catch (_) { + // 실패한 결과를 붙들고 있지 않는다: 브라우저를 다시 열면 새로 시도 + _env = null; + rethrow; + } +} class _EdgeViewState extends State<_EdgeView> { @override diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 7e33690..7a67010 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; @@ -11,7 +8,6 @@ import '../core/encode_options.dart'; import '../core/languages.dart'; import '../core/models.dart'; import '../core/output_paths.dart'; -import '../core/playlist.dart'; import 'ai_dialog.dart'; import 'app_actions.dart'; import 'browser_page.dart'; @@ -42,21 +38,8 @@ class HomePage extends StatelessWidget { return ListenableBuilder( listenable: c, builder: (context, _) => Scaffold( - // 탐색기에서 동영상 · 폴더를 끌어다 놓으면 동영상만 추가 - body: DropTarget( - onDragDone: (d) async { - final videos = await collectVideos(d.files.map((f) => f.path), - isDirectory: (x) => FileSystemEntity.isDirectory(x), - listDir: (x) async => Directory(x).list().map((e) => e.path).toList()); - if (videos.isEmpty) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('끌어다 놓은 항목에 동영상이 없습니다.'))); - } - return; - } - await c.addVideos(videos); - }, + // 끌어다 놓기는 앱 전체에서 받는다 (ui/app_drop.dart 의 AppDropArea - 어느 화면에서 놓아도 이 목록에 추가) + body: SizedBox( child: Column( children: [ _TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks), @@ -119,106 +102,133 @@ class _TopBar extends StatelessWidget { final batch = c.batchTargets; final canBatch = batch.isNotEmpty && c.ffmpegVersion != null; final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; - return Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 16, right: appBarRightPadding), - child: Row( - children: [ - // 프로그램 아이콘 (tool/make_icon.py 로 생성) - Image.asset('assets/icon/app_icon_256.png', width: 28, height: 28, filterQuality: FilterQuality.medium), - const SizedBox(width: 8), - const Text('JJ_MKVMaker', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), - const SizedBox(width: 24), - // 왼쪽 묶음: 창이 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 버튼이 밀려나지 않도록) - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - OutlinedButton.icon( - onPressed: c.pickVideos, - icon: const Icon(Icons.add, size: 18), - label: const Text('동영상 추가'), - ), - if (c.aiAvailable) ...[ - const SizedBox(width: 8), - Tooltip( - message: _batchHint(c, 'AI 자막을 만듭니다'), - child: OutlinedButton.icon( - onPressed: canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null, - icon: const Icon(Icons.auto_awesome, size: 18), - label: Text('자막 만들기$count'), - ), - ), - const SizedBox(width: 8), - Tooltip( - message: _batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다'), - child: OutlinedButton.icon( - onPressed: canBatch - ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) - : null, - icon: const Icon(Icons.auto_mode, size: 18), - label: Text('자막 만들기 & MKV 만들기$count'), - ), - ), - ], - ]), - ), - ), - const SizedBox(width: 8), - if (c.busy) ...[ - JobIndicator(c: c), + // 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → CPU · MEM 숨김. + // (오른쪽의 화면 크기 · 환경 설정 · 종료 버튼은 어떤 너비에서도 밀려나지 않게) + return LayoutBuilder(builder: (context, box) { + final hasAi = c.aiAvailable; + final hasPlayer = c.services.createMediaPlayer != null; + var title = true, leftText = true, rightText = true, usage = c.usage != null, jobText = true; + // 각 부분의 너비 (실제 화면에서 잰 값) + double need() => + 16 + appBarRightPadding + 36 + (title ? 126 : 0) + 24 + + (leftText ? 153 + (hasAi ? 161 + 298 : 0) : 40 + (hasAi ? 96 : 0)) + 8 + + (c.busy ? (jobText ? 350 : 60) : 0) + + (hasPlayer ? (rightText ? 201 : 48) : 0) + + 166 + (c.busy && jobText ? 60 : 0) + + (bookmarks != null ? (rightText ? 138 : 48) : 0) + + (downloads != null ? 162 : 0) + + (usage ? 220 : 0) + 96 + 92 + + (c.checked.isEmpty ? 0 : 3 * 26) + + 8; // 여유 + final w = box.maxWidth; + if (need() > w) title = false; + if (need() > w) jobText = false; + if (need() > w) leftText = false; + if (need() > w) rightText = false; + if (need() > w) usage = false; + + // 글이 있는 버튼, 또는 (좁을 때) 아이콘만 있는 버튼 + Widget action(IconData icon, String label, VoidCallback? onPressed, + {required bool text, required String tip, Color? color}) { + return Tooltip( + message: tip, + child: text + ? OutlinedButton.icon( + onPressed: onPressed, icon: Icon(icon, size: 18, color: color), label: Text(label)) + : IconButton.outlined( + onPressed: onPressed, + icon: Icon(icon, size: 20, color: onPressed == null ? null : color), + style: IconButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: const BorderSide(color: JjColors.border))), + ); + } + + return Container( + height: appBarHeight, + color: JjColors.panel, + padding: const EdgeInsets.only(left: 16, right: appBarRightPadding), + child: Row( + children: [ + // 프로그램 아이콘 (tool/make_icon.py 로 생성) + Image.asset('assets/icon/app_icon_256.png', width: 28, height: 28, filterQuality: FilterQuality.medium), const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: c.cancel, - icon: const Icon(Icons.stop, size: 18, color: JjColors.danger), - label: const Text('취소'), - ), - const SizedBox(width: 8), - ], - // 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개) - if (c.services.createMediaPlayer != null) ...[ - Tooltip( - message: c.checked.isEmpty - ? '지금 보고 있는 동영상을 재생합니다 (여러 개는 목록에서 체크)' - : '체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서 재생합니다', - child: OutlinedButton.icon( - onPressed: batch.isEmpty - ? null - : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), - icon: const Icon(Icons.playlist_play, size: 20), - label: Text('선택한 파일 재생$count'), + if (title) const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + const SizedBox(width: 24), + // 왼쪽 묶음: 그래도 모자라면 옆으로 밀어 볼 수 있게 + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + action(Icons.add, '동영상 추가', c.pickVideos, + text: leftText, tip: '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'), + if (hasAi) ...[ + const SizedBox(width: 8), + action(Icons.auto_awesome, '자막 만들기$count', + canBatch ? () => showAiDialog(context, c, batch.first, targets: batch) : null, + text: leftText, tip: '자막 만들기: ${_batchHint(c, 'AI 자막을 만듭니다')}'), + const SizedBox(width: 8), + action( + Icons.auto_mode, + '자막 만들기 & MKV 만들기$count', + canBatch + ? () => showAiDialog(context, c, batch.first, targets: batch, thenBuild: true) + : null, + text: leftText, + tip: '자막 만들기 & MKV 만들기: ${_batchHint(c, 'AI 자막을 만들고 이어서 MKV 로 만듭니다')}'), + ], + ]), ), ), const SizedBox(width: 8), - ], - FilledButton.icon( - // 체크한 동영상이 있으면 그것만, 없으면 목록 전체 - onPressed: !canBuild ? null : (c.checked.isEmpty ? c.buildAll : () => c.buildVideos(batch)), - icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), - label: Text('MKV 만들기$count${c.busy ? ' (대기열)' : ''}'), - ), - if (bookmarks != null) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))), - icon: const Icon(Icons.public, size: 18), - label: const Text('브라우저'), + if (c.busy) ...[ + JobIndicator(c: c, compact: !jobText, iconOnly: !jobText), + const SizedBox(width: 8), + action(Icons.stop, '취소', c.cancel, text: jobText, tip: '작업 취소', color: JjColors.danger), + const SizedBox(width: 8), + ], + // 체크한 동영상을 목록에 보이는 순서대로 이어서 재생 (체크가 없으면 보고 있는 한 개) + if (hasPlayer) ...[ + action( + Icons.playlist_play, + '선택한 파일 재생$count', + batch.isEmpty + ? null + : () => playFiles(context, c, [for (final v in batch) v.path], keepOrder: true), + text: rightText, + tip: c.checked.isEmpty + ? '선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)' + : '선택한 파일 재생: 체크한 동영상 ${c.checked.length}개를 목록 순서대로 이어서'), + const SizedBox(width: 8), + ], + FilledButton.icon( + // 체크한 동영상이 있으면 그것만, 없으면 목록 전체 + onPressed: !canBuild ? null : (c.checked.isEmpty ? c.buildAll : () => c.buildVideos(batch)), + icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), + label: Text('MKV 만들기$count${c.busy && jobText ? ' (대기열)' : ''}'), ), + if (bookmarks != null) ...[ + const SizedBox(width: 8), + action( + Icons.public, + '브라우저', + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks!))), + text: rightText, + tip: '웹 브라우저'), + ], + if (downloads != null) ...[ + const SizedBox(width: 12), + _DownloadBox(d: downloads!), + ], + // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 + AppActions(c: c, onExit: onExit, showUsage: usage), ], - if (downloads != null) ...[ - const SizedBox(width: 12), - _DownloadBox(d: downloads!), - ], - // 환경 설정 · 종료: 모든 화면에서 같은 자리 - AppActions(c: c, onExit: onExit), - ], - ), - ); + ), + ); + }); } } @@ -434,6 +444,55 @@ class _VideoTile extends StatelessWidget { final VideoItem v; const _VideoTile({required this.c, required this.v}); + Future _menu(BuildContext context, Offset global) async { + c.select(v); + final busy = v.status == JobStatus.running; + PopupMenuItem item(String id, IconData icon, String label, {bool enabled = true}) => PopupMenuItem( + value: id, + enabled: enabled, + height: 36, + child: Row(children: [ + Icon(icon, size: 18, color: enabled ? JjColors.text : JjColors.textDim), + const SizedBox(width: 10), + Text(label, style: const TextStyle(fontSize: 13)), + ]), + ); + // 화면 크기 배율이 걸려 있어도 누른 자리에 뜨도록 메뉴가 그려질 곳 기준으로 바꾼다 + final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; + final at = overlay.globalToLocal(global); + final pick = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx, at.dy, overlay.size.width - at.dx, overlay.size.height - at.dy), + items: [ + item('folder', Icons.folder_open, '대상 폴더 열기'), + if (c.services.createMediaPlayer != null) item('play', Icons.play_arrow, '재생'), + const PopupMenuDivider(), + if (c.aiAvailable) + item('ai', Icons.auto_awesome, 'AI 자막 만들기', enabled: !busy && c.ffmpegVersion != null), + if (c.subtitleProvider != null) + item('search', Icons.travel_explore, '인터넷 자막 찾기', enabled: !busy), + item('subtitle', Icons.subtitles_outlined, '자막 파일 추가', enabled: !busy), + const PopupMenuDivider(), + item('remove', Icons.close, '삭제 (목록에서 제거)', enabled: !busy), + ], + ); + if (pick == null || !context.mounted) return; + switch (pick) { + case 'folder': + await c.services.shell.revealFile(v.path); + case 'play': + await playFiles(context, c, [v.path]); + case 'ai': + await showAiDialog(context, c, v, targets: [v]); + case 'search': + await showSubtitleSearch(context, c, v); + case 'subtitle': + await c.pickSubtitlesFor(v); + case 'remove': + c.removeVideo(v); + } + } + @override Widget build(BuildContext context) { final isSel = c.selected == v; @@ -441,6 +500,8 @@ class _VideoTile extends StatelessWidget { return InkWell( onTap: () => c.select(v), onDoubleTap: () => playFiles(context, c, [v.path]), + // 오른쪽 클릭: 이 동영상으로 할 수 있는 일 + onSecondaryTapDown: (d) => _menu(context, d.globalPosition), child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.fromLTRB(2, 10, 4, 10), @@ -579,6 +640,12 @@ class _VideoDetail extends StatelessWidget { label: const Text('재생'), ), ], + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => c.services.shell.revealFile(v.path), + icon: const Icon(Icons.folder_open, size: 18), + label: const Text('폴더 열기'), + ), ]), const SizedBox(height: 4), SelectableText(v.path, diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index c38e64e..0e8a72b 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -12,6 +12,7 @@ import '../core/subtitle_detector.dart'; import '../services/app_shell.dart'; import 'setup_dialog.dart'; import 'update_dialog.dart'; +import '../app/settings.dart'; import 'app_actions.dart'; import 'theme.dart'; @@ -260,6 +261,29 @@ class _SettingsPageState extends State { child: ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ + _section('화면'), + ListTile( + title: const Text('기본 화면 크기'), + subtitle: Text('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' + '(지금 ${(s.uiScale * 100).round()}%)'), + trailing: DropdownButton( + value: s.uiScaleDefault, + items: [ + for (var v = AppSettings.uiScaleMin; v <= AppSettings.uiScaleMax + 0.001; v += 0.1) + DropdownMenuItem( + value: AppSettings.clampUiScale(v), + child: Text('${(v * 100).round()}%${(v - 1).abs() < 0.001 ? ' (보통)' : ''}')), + // 5% 단위로 저장된 값이 목록에 없을 때 + if (((s.uiScaleDefault * 100).round() % 10) != 0) + DropdownMenuItem( + value: s.uiScaleDefault, child: Text('${(s.uiScaleDefault * 100).round()}%')), + ], + // 기본 크기를 바꾸면 지금 크기도 그 크기로 + onChanged: (v) => c.updateSettings((x) => x + ..uiScaleDefault = v! + ..uiScale = v), + ), + ), _section('시작 · 웹 브라우저'), ListTile( title: const Text('처음 화면'), diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 3896fff..affa273 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -26,86 +26,135 @@ class WorkPanel extends StatelessWidget { @override Widget build(BuildContext context) => ListenableBuilder( - listenable: Listenable.merge([c, ?downloads]), - builder: (context, _) => Container( - color: JjColors.panel, - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _header(context), - const Divider(height: 1), - Expanded( - child: ListView(padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), children: [ + listenable: Listenable.merge([c, ?downloads]), + builder: (context, _) => Container( + color: JjColors.panel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(context), + const Divider(height: 1), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + children: [ ..._jobs(), _title('편집 목록 ${c.videos.length}개'), - if (c.videos.isEmpty) const _Dim('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).'), + if (c.videos.isEmpty) + const _Dim('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).'), for (final v in c.videos) _VideoRow(v: v), if (downloads != null) ...[ const SizedBox(height: 12), - _title('다운로드 ${downloads!.tasks.length}개', - action: TextButton( - onPressed: () => Navigator.push( - context, MaterialPageRoute(builder: (_) => DownloadsPage(d: downloads!))), - child: const Text('전체 목록', style: TextStyle(fontSize: 12)), - )), - if (downloads!.tasks.isEmpty) const _Dim('동영상 페이지에서 [다운로드] 를 누르세요.'), - for (final t in downloads!.tasks.reversed.take(30)) _DownloadRow(c: c, t: t), + _title( + '다운로드 ${downloads!.tasks.length}개', + action: TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DownloadsPage(d: downloads!), + ), + ), + child: const Text( + '전체 목록', + style: TextStyle(fontSize: 12), + ), + ), + ), + if (downloads!.tasks.isEmpty) + const _Dim('동영상 페이지에서 [다운로드] 를 누르세요.'), + for (final t in downloads!.tasks.reversed.take(30)) + _DownloadRow(c: c, t: t), ], - ]), + ], ), - ]), - ), - ); + ), + ], + ), + ), + ); Widget _header(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(12, 6, 4, 6), - child: Row(children: [ - const Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - const Expanded(child: Text('작업 현황', style: TextStyle(fontWeight: FontWeight.w600))), - IconButton(tooltip: '동영상 추가', iconSize: 18, icon: const Icon(Icons.add), onPressed: c.pickVideos), + padding: const EdgeInsets.fromLTRB(12, 6, 4, 6), + child: Row( + children: [ + const Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + const Expanded( + child: Text('작업 현황', style: TextStyle(fontWeight: FontWeight.w600)), + ), + IconButton( + tooltip: '동영상 추가', + iconSize: 18, + icon: const Icon(Icons.add), + onPressed: c.pickVideos, + ), + IconButton( + tooltip: c.busy ? 'MKV 만들기 (대기열에 추가)' : 'MKV 만들기', + iconSize: 18, + icon: const Icon(Icons.play_arrow, color: JjColors.accent), + onPressed: c.videos.isEmpty || c.ffmpegVersion == null + ? null + : c.buildAll, + ), + if (c.busy) IconButton( - tooltip: c.busy ? 'MKV 만들기 (대기열에 추가)' : 'MKV 만들기', + tooltip: '모든 작업 취소', iconSize: 18, - icon: const Icon(Icons.play_arrow, color: JjColors.accent), - onPressed: c.videos.isEmpty || c.ffmpegVersion == null ? null : c.buildAll, + icon: const Icon(Icons.stop, color: JjColors.danger), + onPressed: c.cancel, ), - if (c.busy) - IconButton( - tooltip: '모든 작업 취소', - iconSize: 18, - icon: const Icon(Icons.stop, color: JjColors.danger), - onPressed: c.cancel, - ), - IconButton( - tooltip: 'MKV 화면으로 (전체 화면)', - iconSize: 18, - icon: const Icon(Icons.open_in_full), - onPressed: onOpenHome, - ), - IconButton(tooltip: '작업 현황 닫기', iconSize: 18, icon: const Icon(Icons.close), onPressed: onClose), - ]), - ); + IconButton( + tooltip: 'MKV 화면으로 (전체 화면)', + iconSize: 18, + icon: const Icon(Icons.open_in_full), + onPressed: onOpenHome, + ), + IconButton( + tooltip: '작업 현황 닫기', + iconSize: 18, + icon: const Icon(Icons.close), + onPressed: onClose, + ), + ], + ), + ); List _jobs() => [ - _title('작업'), - if (!c.busy) - const _Dim('쉬는 중') - else ...[ - Text('▶ ${c.currentJob ?? '작업 중'}', style: const TextStyle(fontSize: 12, color: JjColors.accent)), - for (final (i, j) in c.pendingJobs.indexed) - Text('${i + 1}. $j (대기)', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), - ], - const SizedBox(height: 12), - ]; + _title('작업'), + if (!c.busy) + const _Dim('쉬는 중') + else ...[ + Text( + '▶ ${c.currentJob ?? '작업 중'}', + style: const TextStyle(fontSize: 12, color: JjColors.accent), + ), + for (final (i, j) in c.pendingJobs.indexed) + Text( + '${i + 1}. $j (대기)', + style: const TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ], + const SizedBox(height: 12), + ]; Widget _title(String t, {Widget? action}) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row(children: [ - Expanded( - child: Text(t, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.textDim))), - ?action, - ]), - ); + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Expanded( + child: Text( + t, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: JjColors.textDim, + ), + ), + ), + ?action, + ], + ), + ); } class _Dim extends StatelessWidget { @@ -113,9 +162,12 @@ class _Dim extends StatelessWidget { const _Dim(this.text); @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text(text, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), - ); + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + text, + style: const TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ); } class _VideoRow extends StatelessWidget { @@ -133,31 +185,52 @@ class _VideoRow extends StatelessWidget { final sub = v.status == JobStatus.running ? '${v.phase ?? ''} ${(v.progress * 100).round()}%' : v.phase ?? - switch (v.status) { - JobStatus.done => 'MKV 완성', - JobStatus.failed => '실패: ${v.message ?? ''}', - _ => '자막 ${v.subtitles.where((s) => s.enabled).length}개', - }; + switch (v.status) { + JobStatus.done => 'MKV 완성', + JobStatus.failed => '실패: ${v.message ?? ''}', + _ => '자막 ${v.subtitles.where((s) => s.enabled).length}개', + }; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 6), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(v.fileName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), - if (v.status == JobStatus.running) - Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: LinearProgressIndicator(value: v.progress <= 0 ? null : v.progress, minHeight: 3), - ), - Text(sub, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: v.status == JobStatus.failed ? JjColors.danger : color)), - ]), - ), - ]), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + v.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + if (v.status == JobStatus.running) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: LinearProgressIndicator( + value: v.progress <= 0 ? null : v.progress, + minHeight: 3, + ), + ), + Text( + sub, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: v.status == JobStatus.failed + ? JjColors.danger + : color, + ), + ), + ], + ), + ), + ], + ), ); } } @@ -169,29 +242,47 @@ class _DownloadRow extends StatelessWidget { @override Widget build(BuildContext context) { - final files = t.state == DownloadState.done ? DownloadManager.videoFilesOf(t) : const []; + final files = t.state == DownloadState.done + ? DownloadManager.videoFilesOf(t) + : const []; final inList = - files.isNotEmpty && files.every((f) => c.videos.any((v) => v.path.toLowerCase() == f.toLowerCase())); + files.isNotEmpty && + files.every( + (f) => c.videos.any((v) => v.path.toLowerCase() == f.toLowerCase()), + ); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Row(children: [ - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), - const SizedBox(height: 2), - if (t.state != DownloadState.cancelled) DownloadProgressBar(t: t, height: 12), - ]), - ), - if (files.isNotEmpty) - IconButton( - tooltip: inList ? '편집 목록에 있음' : '편집 목록에 추가', - iconSize: 16, - visualDensity: VisualDensity.compact, - icon: Icon(inList ? Icons.playlist_add_check : Icons.playlist_add, - color: inList ? JjColors.success : JjColors.accent), - onPressed: inList ? null : () => c.addDownloaded(files), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 2), + if (t.state != DownloadState.cancelled) + DownloadProgressBar(t: t, height: 12), + ], + ), ), - ]), + if (files.isNotEmpty) + IconButton( + tooltip: inList ? '편집 목록에 있음' : '편집 목록에 추가', + iconSize: 16, + visualDensity: VisualDensity.compact, + icon: Icon( + inList ? Icons.playlist_add_check : Icons.playlist_add, + color: inList ? JjColors.success : JjColors.accent, + ), + onPressed: inList ? null : () => c.addDownloaded(files), + ), + ], + ), ); } } @@ -200,37 +291,59 @@ class _DownloadRow extends StatelessWidget { class JobIndicator extends StatelessWidget { final AppController c; final bool compact; - const JobIndicator({super.key, required this.c, this.compact = false}); + + /// 돌아가는 표시만 (글은 마우스를 올리면) + final bool iconOnly; + const JobIndicator({ + super.key, + required this.c, + this.compact = false, + this.iconOnly = false, + }); @override Widget build(BuildContext context) { if (!c.busy) return const SizedBox(); - final running = c.videos.where((v) => v.status == JobStatus.running).toList(); - final p = running.isEmpty ? null : running.map((v) => v.progress).reduce((a, b) => a + b) / running.length; + final running = c.videos + .where((v) => v.status == JobStatus.running) + .toList(); + final p = running.isEmpty + ? null + : running.map((v) => v.progress).reduce((a, b) => a + b) / + running.length; final wait = c.pendingJobs.length; return Tooltip( message: [ '지금: ${c.currentJob ?? ''}', - for (final v in running) ' · ${v.fileName} — ${v.phase ?? ''} ${(v.progress * 100).round()}%', + for (final v in running) + ' · ${v.fileName} — ${v.phase ?? ''} ${(v.progress * 100).round()}%', if (wait > 0) '대기 $wait개:', for (final j in c.pendingJobs) ' · $j', ].join('\n'), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - SizedBox( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, value: p == null || p == 0 ? null : p)), - const SizedBox(width: 6), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: compact ? 160 : 240), - child: Text( - '${c.currentJob ?? '작업 중'}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? ' · 대기 $wait' : ''}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, color: JjColors.accent), + child: CircularProgressIndicator( + strokeWidth: 2, + value: p == null || p == 0 ? null : p, + ), ), - ), - ]), + if (!iconOnly) const SizedBox(width: 6), + if (!iconOnly) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 160 : 240), + child: Text( + '${c.currentJob ?? '작업 중'}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? ' · 대기 $wait' : ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: JjColors.accent), + ), + ), + ], + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index cc66489..a3761c7 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: 2026.9.30+1 +version: 2026.9.30+2 environment: sdk: ^3.13.4 diff --git a/test/drop_test.dart b/test/drop_test.dart new file mode 100644 index 0000000..8a48438 --- /dev/null +++ b/test/drop_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +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/download_manager.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/ui/app_drop.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:path/path.dart' as p; + +AppController _plain() => + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + +/// 탐색기에서 끌어다 놓을 때 Windows 쪽 플러그인이 보내는 것과 같은 메시지 +Future _native(WidgetTester tester, String method, Object? args) async { + const codec = StandardMethodCodec(); + await tester.binding.defaultBinaryMessenger + .handlePlatformMessage('desktop_drop', codec.encodeMethodCall(MethodCall(method, args)), (_) {}); +} + +void main() { + test('끌어다 놓은 것에서 동영상 고르기: 파일 · 폴더 · jj_mkv 안의 파일 · 이미 있는 것', () async { + final dir = Directory.systemTemp.createTempSync('jj_drop_'); + addTearDown(() => dir.deleteSync(recursive: true)); + String make(String rel) { + final f = File(p.join(dir.path, rel))..createSync(recursive: true); + f.writeAsStringSync('x'); + return f.path; + } + + final a = make('a.mp4'); + final made = make(p.join('jj_mkv', '만든 것.mkv')); + make(p.join('시리즈', '1화.mkv')); + make(p.join('시리즈', '2화.mkv')); + make(p.join('시리즈', 'jj_mkv', '1화.mkv')); // 폴더째 놓을 때는 출력 폴더를 건너뜀 + final note = make('메모.txt'); + + final c = _plain(); + // 파일 하나 + expect(await addDroppedVideos(c, [a]), (1, 1)); + // 직접 놓은 파일은 jj_mkv 안이어도 추가, 동영상이 아닌 것은 무시, 폴더는 안쪽까지 + expect(await addDroppedVideos(c, [made, note, p.join(dir.path, '시리즈')]), (3, 3)); + expect([for (final v in c.videos) v.fileName], ['a.mp4', '만든 것.mkv', '1화.mkv', '2화.mkv']); + // 이미 있는 것 + expect(await addDroppedVideos(c, [a, made]), (0, 2)); + // 동영상이 없음 + expect(await addDroppedVideos(c, [note]), (0, 0)); + expect(c.videos, hasLength(4)); + }); + + testWidgets('다른 화면 (다운로드) 에서 끌어다 놓아도 MKV 만들기의 동영상 목록에 추가 + 안내', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_drop2_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = p.join(dir.path, '끌어온 영상.mp4'); + File(file).writeAsStringSync('x'); + + final c = _plain(); + final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => AppDropArea(c: c, child: child!), + home: DownloadsPage(d: d), // MKV 화면이 아닌 화면 + )); + await tester.pump(); + + // 끌고 들어오면 안내 + await _native(tester, 'entered', [400.0, 300.0]); + await tester.pump(); + expect(find.textContaining('여기에 놓으면'), findsOneWidget); + + // 놓기 + await tester.runAsync(() async { + await _native(tester, 'performOperation', [file]); + for (var i = 0; i < 50 && c.videos.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 20)); + } + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + expect([for (final v in c.videos) v.fileName], ['끌어온 영상.mp4']); + expect(c.selected, c.videos.single); + expect(find.textContaining('여기에 놓으면'), findsNothing); + expect(find.textContaining('동영상 목록에 1개를 추가했습니다'), findsOneWidget); + + // 같은 것을 다시 놓으면 "이미 있음" + await _native(tester, 'entered', [400.0, 300.0]); + await tester.pump(); + await tester.runAsync(() async { + await _native(tester, 'performOperation', [file]); + await Future.delayed(const Duration(milliseconds: 200)); + }); + // 앞의 안내가 사라지고 새 안내가 나올 때까지 + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(c.videos, hasLength(1)); + expect(find.textContaining('이미 동영상 목록에 있습니다'), findsOneWidget); + d.dispose(); + }); +} diff --git a/test/scale_menu_test.dart b/test/scale_menu_test.dart new file mode 100644 index 0000000..38e2977 --- /dev/null +++ b/test/scale_menu_test.dart @@ -0,0 +1,168 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart' show kSecondaryButton; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/models.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/app_shell.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/services/system_usage.dart'; +import 'package:jj_mkvmaker/ui/app_actions.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:path/path.dart' as p; + +class _Shell extends NoopShell { + final revealed = []; + @override + Future revealFile(String path) async => revealed.add(path); +} + +void main() { + test('화면 크기 설정: 범위 · 5% 단위 · 저장', () { + expect(AppSettings.clampUiScale(null), 1.0); + expect(AppSettings.clampUiScale(0.1), 0.7); + expect(AppSettings.clampUiScale(9), 1.6); + expect(AppSettings.clampUiScale(1.1000000001), 1.1); + final s = AppSettings() + ..uiScale = 1.2 + ..uiScaleDefault = 0.9; + final back = AppSettings.fromJson(s.toJson()); + expect([back.uiScale, back.uiScaleDefault], [1.2, 0.9]); + }); + + testWidgets('화면 크기 버튼: − · + 로 10% 씩, 숫자를 누르면 기본 크기로, 화면이 실제로 커진다', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => UiScaler(c: c, child: child!), + home: Scaffold( + body: Column(children: [ + Row(children: [const Spacer(), AppActions(c: c, onExit: () {})]), + const SizedBox(width: 100, height: 100, key: Key('box')), + ]), + ), + )); + expect(find.text('100%'), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('box'))), const Size(100, 100)); + final settingsAt = tester.getCenter(find.byTooltip('환경 설정')); + // − · 숫자 · + 는 환경 설정 버튼의 왼쪽 + expect(tester.getCenter(find.byTooltip('화면 크게')).dx, lessThan(settingsAt.dx)); + + await tester.tap(find.byTooltip('화면 크게')); + await tester.pump(); + await tester.tap(find.byTooltip('화면 크게')); + await tester.pump(); + expect(c.settings.uiScale, 1.2); + expect(find.text('120%'), findsOneWidget); + // 화면에 그려지는 크기가 1.2 배 + final r = tester.getRect(find.byKey(const Key('box'))); + expect(r.width, closeTo(120, 0.01)); + // 안쪽은 1200/1.2 = 1000 너비의 화면이라고 여기고 배치 → 오른쪽 끝 버튼은 여전히 창 오른쪽 끝 + expect(tester.getRect(find.byTooltip('종료')).right, closeTo(1200, 10)); + + await tester.tap(find.byTooltip('화면 작게')); + await tester.pump(); + expect(c.settings.uiScale, 1.1); + + // 기본 크기 90% 로 정해 두고 숫자를 누르면 그 크기로 + c.settings.uiScaleDefault = 0.9; + await tester.tap(find.text('110%')); + await tester.pump(); + expect(c.settings.uiScale, 0.9); + expect(find.text('90%'), findsOneWidget); + + // 끝까지 줄이면 − 가 꺼진다 + c.settings.uiScale = AppSettings.uiScaleMin; + c.updateSettings((_) {}); + await tester.pump(); + expect(tester.widget(find.widgetWithIcon(IconButton, Icons.remove)).onPressed, isNull); + }); + + testWidgets('동영상 목록 오른쪽 클릭 메뉴 · [폴더 열기] 버튼', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final shell = _Shell(); + final c = AppController( + PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); + c.videos.addAll([VideoItem(r'D:\v\첫째.mp4'), VideoItem(r'D:\v\둘째.mp4')]); + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + + // [폴더 열기] (재생 버튼 옆): 지금 보고 있는 동영상 + await tester.tap(find.text('폴더 열기')); + expect(shell.revealed, [r'D:\v\첫째.mp4']); + + // 둘째 줄에서 오른쪽 클릭 → 그 줄이 선택되고 메뉴 + await tester.tapAt(tester.getCenter(find.text('둘째.mp4').first), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + expect(c.selected, c.videos[1]); + for (final label in ['대상 폴더 열기', '자막 파일 추가', '삭제 (목록에서 제거)']) { + expect(find.text(label), findsWidgets, reason: label); + } + await tester.tap(find.text('대상 폴더 열기')); + await tester.pumpAndSettle(); + expect(shell.revealed.last, r'D:\v\둘째.mp4'); + + // 삭제 + await tester.tapAt(tester.getCenter(find.text('둘째.mp4').first), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + await tester.tap(find.text('삭제 (목록에서 제거)')); + await tester.pumpAndSettle(); + expect([for (final v in c.videos) v.fileName], ['첫째.mp4']); + }); + + testWidgets('MKV 화면 위쪽 막대: 창이 좁아도 넘치지 않고 설정 · 종료 버튼이 창 안에 있다', (tester) async { + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_bar_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + createRecognizer: () => throw UnimplementedError(), + createTranslator: () => throw UnimplementedError(), + createMediaPlayer: () => throw UnimplementedError(), + usage: _Usage(), + )); + c.videos.addAll([VideoItem(r'D:\v\a.mp4'), VideoItem(r'D:\v\b.mp4')]); + c.selected = c.videos.first; + c.toggleChecked(c.videos.first); + final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); + final bm = BookmarksController(p.join(dir.path, 'bm.json')); + + for (final width in [1900.0, 1600.0, 1300.0, 1100.0, 900.0]) { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + await tester.pumpWidget(MaterialApp( + key: UniqueKey(), home: HomePage(c: c, downloads: d, bookmarks: bm, onExit: () {}))); + await tester.pump(); + expect(tester.takeException(), isNull, reason: '너비 $width 에서 넘침'); + final exit = tester.getRect(find.byTooltip('종료')); + expect(exit.right, lessThanOrEqualTo(width), reason: '너비 $width: 종료 버튼이 창 밖'); + expect(tester.getRect(find.byTooltip('화면 크게')).left, greaterThan(0)); + // 넓을 때는 글이 있는 버튼, 좁을 때는 아이콘만 (기능은 그대로) + expect(find.text('자막 만들기 & MKV 만들기 (1)'), width >= 1900 ? findsOneWidget : anything); + expect(find.textContaining('MKV 만들기 (1)'), findsWidgets); + } + // 가장 좁을 때: 아이콘만 남고 설명은 마우스를 올리면 + expect(find.text('동영상 추가'), findsNothing); + expect(find.byTooltip(RegExp('^동영상 추가')), findsOneWidget); + expect(find.byTooltip(RegExp('^선택한 파일 재생')), findsOneWidget); + d.dispose(); + }); +} + +class _Usage implements SystemUsage { + @override + UsageSample? read() => const UsageSample(cpu: 0.5, memUsed: 8 << 30, memTotal: 16 << 30); + @override + (int, int)? disk(String path) => (100 << 30, 500 << 30); +}