From 6184b6ea71659812bf18f7683d75564424f2f328 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:54:02 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95:=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=EB=B3=84=20=EC=A0=95=EB=A6=AC=20(=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20=C2=B7=20=ED=99=94=EB=A9=B4=20=C2=B7=20MKV=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=C2=B7=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EC=83=9D=20=C2=B7=20=EC=9B=B9=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=C2=B7=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=C2=B7=20=EC=8B=A4=ED=96=89=20=C2=B7=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4)=20+=20=EB=A7=A8=20=EC=9C=84=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 4 + assets/l10n/ja.json | 4 + assets/l10n/zh-Hans.json | 4 + integration_test/settings_shot_test.dart | 38 + lib/ui/settings_page.dart | 858 ++++++++++++----------- 5 files changed, 512 insertions(+), 396 deletions(-) create mode 100644 integration_test/settings_shot_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3130ed9..aa9aa1d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -435,6 +435,7 @@ "실패: {0}": "Failed: {0}", "실패: {0}\n{1}": "Failed: {0}\n{1}", "실행": "Run", + "실행 · 종료": "Run · quit", "실행 취소": "Undo", "싱크": "Sync", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)", @@ -558,12 +559,14 @@ "인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)", "인터넷 자막 찾기": "Find subtitles online", "인터넷 자막 찾기{0}": "Find subtitles online{0}", + "일반": "General", "일본어": "Japanese", "일시정지": "Pause", "자동": "Auto", "자동 감지": "Auto-detect", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "Automatic installation is not possible here. Please download it from the page yourself.", "자막": "Subtitles", + "자막 (AI · 인터넷)": "Subtitles (AI · online)", "자막 (S)": "Subtitles (S)", "자막 {0}개": "{0} subtitles", "자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "Downloaded {1} of {0} subtitles. Check the job log below.", @@ -748,6 +751,7 @@ "푸는 중": "Extracting", "프랑스어": "French", "프로그램": "Program", + "프로그램 정보": "About", "프로그램 정보 · 업데이트": "About · updates", "프로그램 직접 선택…": "Choose a program…", "프로그램 폴더": "Program folder", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4b8f1ee..6ead769 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -435,6 +435,7 @@ "실패: {0}": "失敗: {0}", "실패: {0}\n{1}": "失敗: {0}\n{1}", "실행": "実行", + "실행 · 종료": "実行・終了", "실행 취소": "元に戻す", "싱크": "同期", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)", @@ -558,12 +559,14 @@ "인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)", "인터넷 자막 찾기": "インターネットで字幕を探す", "인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}", + "일반": "一般", "일본어": "日本語", "일시정지": "一時停止", "자동": "自動", "자동 감지": "自動検出", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "自動インストールできない場所です。ページから直接ダウンロードしてください。", "자막": "字幕", + "자막 (AI · 인터넷)": "字幕(AI・インターネット)", "자막 (S)": "字幕(S)", "자막 {0}개": "字幕 {0}個", "자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "字幕 {0}個中 {1}個をダウンロードしました。下の作業記録を確認してください。", @@ -748,6 +751,7 @@ "푸는 중": "展開中", "프랑스어": "フランス語", "프로그램": "プログラム", + "프로그램 정보": "プログラム情報", "프로그램 정보 · 업데이트": "プログラム情報・更新", "프로그램 직접 선택…": "プログラムを直接選択…", "프로그램 폴더": "プログラムフォルダー", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 40af1f6..4620120 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -435,6 +435,7 @@ "실패: {0}": "失败: {0}", "실패: {0}\n{1}": "失败: {0}\n{1}", "실행": "运行", + "실행 · 종료": "运行 · 退出", "실행 취소": "撤销", "싱크": "同步", "쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)", @@ -558,12 +559,14 @@ "인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)", "인터넷 자막 찾기": "在线查找字幕", "인터넷 자막 찾기{0}": "在线查找字幕{0}", + "일반": "常规", "일본어": "日语", "일시정지": "暂停", "자동": "自动", "자동 감지": "自动检测", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "此位置无法自动安装。请从页面手动下载。", "자막": "字幕", + "자막 (AI · 인터넷)": "字幕(AI · 网络)", "자막 (S)": "字幕(S)", "자막 {0}개": "字幕 {0} 个", "자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "已下载 {0} 个字幕中的 {1} 个。请查看下方的任务记录。", @@ -748,6 +751,7 @@ "푸는 중": "正在解压", "프랑스어": "法语", "프로그램": "程序", + "프로그램 정보": "程序信息", "프로그램 정보 · 업데이트": "程序信息 · 更新", "프로그램 직접 선택…": "手动选择程序…", "프로그램 폴더": "程序文件夹", diff --git a/integration_test/settings_shot_test.dart b/integration_test/settings_shot_test.dart new file mode 100644 index 0000000..76a7607 --- /dev/null +++ b/integration_test/settings_shot_test.dart @@ -0,0 +1,38 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; + +/// 환경 설정 화면을 실제 글꼴로 그려 PNG 로 저장 (JJ_SHOT_DIR). 화면 배치 확인용. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('환경 설정 화면 그림', (tester) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey(); + for (final (name, size) in [('wide', const Size(1400, 2600)), ('narrow', const Size(800, 3400))]) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + await tester.pumpWidget(MaterialApp( + theme: buildTheme(), + home: RepaintBoundary(key: key, child: SettingsPage(c: c)), + )); + await tester.pumpAndSettle(); + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$out/settings_$name.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + } + tester.view.reset(); + }); +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index cdcf53c..b16e042 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -267,414 +267,420 @@ class _SettingsPageState extends State { child: ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - // 맨 위: 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게) - _section('${tr('화면 언어')} · Language'), - const LanguageSettings(), - // 지금 버전 · 최신 버전 확인 (Windows · Android) - if (c.services.updater != null) ...[ - _section(tr('프로그램 정보 · 업데이트')), - ListTile( - leading: const Icon(Icons.system_update_alt, color: JjColors.accent), - title: FutureBuilder( - future: c.services.updater!.currentVersion(), - builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'), + // 묶음 바로가기 (누르면 그 묶음으로) + _groupBar(desk), + _group('general', Icons.tune, tr('일반'), [ + // 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위) + const LanguageSettings(), + if (c.services.updater != null) ...[ + ListTile( + leading: const Icon(Icons.system_update_alt, color: JjColors.accent), + title: FutureBuilder( + future: c.services.updater!.currentVersion(), + builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'), + ), + subtitle: Text(tr('GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다')), + trailing: FilledButton.icon( + onPressed: () => checkForUpdate(context, c, manual: true), + icon: const Icon(Icons.refresh, size: 18), + label: Text(tr('최신 버전 확인')), + ), ), - subtitle: Text(tr('GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다')), - trailing: FilledButton.icon( - onPressed: () => checkForUpdate(context, c, manual: true), - icon: const Icon(Icons.refresh, size: 18), - label: Text(tr('최신 버전 확인')), + SwitchListTile( + value: s.autoCheckUpdates, + onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v), + title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')), + ), + ], + ListTile( + title: Text(tr('홈 화면')), + subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')), + trailing: DropdownButton( + value: s.startScreen, + items: [ + DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))), + DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.startScreen = v!), + ), + ), + ]), + _group('display', Icons.aspect_ratio, tr('화면'), [ + if (!desk) + ListTile( + title: Text(tr('화면 방향')), + subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')), + trailing: DropdownButton( + value: s.screenOrientation, + items: [ + DropdownMenuItem(value: 'landscape', child: Text(tr('가로 고정 (기본)'))), + DropdownMenuItem(value: 'portrait', child: Text(tr('세로 고정'))), + DropdownMenuItem(value: 'auto', child: Text(tr('자동'))), + ], + onChanged: (v) { + c.updateSettings((x) => x.screenOrientation = v!); + applyScreenOrientation(v!); + }, + ), + ), + ListTile( + title: Text(tr('기본 화면 크기')), + subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(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 ? tr(' (보통)') : ''}')), + // 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), + ), + ), + ]), + _group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [ + ListTile( + title: Text(tr('동시 MKV 변환 수')), + subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), + trailing: CountSelector( + value: s.maxParallelJobs, + onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), + ), + ), + ListTile( + title: Text(tr('마지막 인코딩 설정을 기억합니다')), + subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])), + ), + _folderTile( + title: tr('MKV · 자막 (jj_mkv)'), + value: s.mkvOutputRoot, + defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'), + onPick: () async { + final d = await _pickDir(tr('MKV · 자막 저장 위치'), s.mkvOutputRoot); + if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d); + }, + onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null), + ), + _folderTile( + title: tr('이동 폴더'), + value: s.moveTargetDir, + defaultText: tr('정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다'), + onPick: () async { + final d = await _pickDir(tr('이동 폴더'), s.moveTargetDir); + if (d != null) await c.updateSettings((x) => x.moveTargetDir = d); + }, + onReset: () => c.updateSettings((x) => x.moveTargetDir = null), + ), + ]), + _group('subtitle', Icons.subtitles_outlined, tr('자막 (AI · 인터넷)'), [ + SwitchListTile( + value: s.askAiOptions, + onChanged: (v) => c.updateSettings((x) => x.askAiOptions = v), + title: Text(tr('시작할 때마다 설정 창 보기')), + subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])), + ), + _subTitle(tr('인터넷 자막 (OpenSubtitles.com)')), + _textTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, + tr('가입 → 프로필 → API consumers → New consumer'), + (v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())), + _textTile(tr('아이디 (선택)'), s.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'), + (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), + _textTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0} 의 설정 파일에 저장됩니다', [desk ? 'PC' : tr('기기')]), + (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), + ]), + _group('play', Icons.play_circle_outline, tr('재생'), [ + ListTile( + title: Text(tr('동영상 하나를 재생할 때')), + subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')), + trailing: DropdownButton( + value: s.playlistMode, + items: [ + for (final m in PlaylistMode.values) DropdownMenuItem(value: m, child: Text(m.label)), + ], + onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), + ), + ), + ListTile( + title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')), + subtitle: Text(desk + ? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)') + : tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')), + trailing: DropdownButton( + value: s.openFileAction, + items: [ + DropdownMenuItem(value: 'play', child: Text(tr('바로 재생 (기본)'))), + DropdownMenuItem(value: 'add', child: Text(tr('편집 목록에 추가 (MKV 만들기 대상)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!), + ), + ), + if (desk) ...[ + ListTile( + title: Text(tr('탐색기에서 연 동영상을 재생할 창')), + subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')), + trailing: DropdownButton( + value: s.openFileWindow, + items: [ + DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))), + DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))), + ], + onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!), + ), + ), + _ExternalPlayers(c: c), + _ContextMenuTile(c: c), + ], + ]), + _group('browser', Icons.public, tr('웹 브라우저'), [ + _textTile(tr('홈 주소'), s.homeUrl, tr('웹 브라우저의 시작 · 🏠 주소'), + (v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())), + SwitchListTile( + value: s.youtubeAdSkip, + onChanged: (v) => c.updateSettings((x) => x.youtubeAdSkip = v), + title: Text(tr('YouTube 광고 자동 건너뛰기')), + subtitle: Text(tr('앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 ' '(넘기는 동안 화면 위쪽에 "광고 건너뛰는 중…")')), + ), + SwitchListTile( + value: s.youtubeAdHide, + onChanged: (v) => c.updateSettings((x) => x.youtubeAdHide = v), + title: Text(tr('YouTube 광고 배너 숨기기')), + subtitle: Text(tr('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')), + ), + if (desk) ...[ + ListTile( + title: Text(tr('브라우저 엔진 (앱 안)')), + subtitle: Text( + trf('Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\n' 'Chrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 ' '(Google 로그인은 막힐 수 있음).' '{1}' '{2}', [CefRuntime.approxDownloadMb, CefRuntime.installed ? trf(' 지금 설치됨 ({0}MB)', [CefRuntime.installedMb()]) : '', s.browserEngine == 'chrome' && !CefRuntime.readyThisRun ? tr(' · 다시 시작해야 Chrome 으로 바뀝니다') : ''])), + isThreeLine: true, + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (s.browserEngine == 'edge' && CefRuntime.installed && !CefRuntime.readyThisRun) + IconButton( + tooltip: tr('내려받은 Chrome 엔진 지우기'), + icon: const Icon(Icons.delete_outline, color: JjColors.textDim), + onPressed: () async { + final ok = await CefRuntime.uninstall(); + c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).')); + setState(() {}); + }, + ), + DropdownButton( + value: s.browserEngine, + items: [ + DropdownMenuItem(value: 'edge', child: Text(tr('Edge (내장)'))), + DropdownMenuItem( + value: 'chrome', + child: Text(CefRuntime.installed ? tr('Chrome (내장)') : tr('Chrome (내려받기)'))), + ], + onChanged: (v) async { + if (v == 'chrome') { + await chooseChromeEngine(context, c); + if (mounted) setState(() {}); + } else { + await c.updateSettings((x) => x.browserEngine = 'edge'); + } + }, + ), + ]), + ), + ListTile( + title: Text(tr('외부 브라우저')), + subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')), + trailing: DropdownButton( + value: s.externalBrowser, + items: [ + DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 브라우저'))), + for (final b in {...c.services.shell.installedBrowsers(), if (s.externalBrowser != 'system') s.externalBrowser}) + DropdownMenuItem(value: b, child: Text(switch (b) { + 'chrome' => 'Chrome', + 'firefox' => 'Firefox', + 'edge' => 'Edge', + 'whale' => 'Whale', + _ => b, + })), + ], + onChanged: (v) => c.updateSettings((x) => x.externalBrowser = v!), + ), + ), + ], + ]), + _group('download', Icons.download_outlined, tr('다운로드'), [ + if (desk) + SwitchListTile( + value: s.clipboardWatch, + onChanged: (v) => c.updateSettings((x) => x.clipboardWatch = v), + title: Text(tr('복사(Ctrl+C)한 주소 자동 다운로드')), + subtitle: Text(tr('YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)')), + ), + SwitchListTile( + value: s.addFinishedDownloads, + onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), + title: Text(tr('완료시 자동 동영상추가')), + subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), + ), + ListTile( + title: Text(tr('YouTube 받을 형식')), + subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')), + trailing: DropdownButton( + value: s.ytContainer, + items: [for (final v in YtContainer.values) DropdownMenuItem(value: v, child: Text(v.label))], + onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!), + ), + ), + ListTile( + enabled: !s.ytContainer.audioOnly, + title: Text(tr('YouTube 화질')), + subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')), + trailing: DropdownButton( + value: s.ytQuality, + items: [for (final v in YtQuality.values) DropdownMenuItem(value: v, child: Text(v.label))], + onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytQuality = v!), ), ), SwitchListTile( - value: s.autoCheckUpdates, - onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v), - title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')), + value: s.ytExpandPlaylists, + onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v), + title: Text(tr('재생목록 주소면 목록 전체 받기')), + subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')), ), - ], - _section(tr('화면')), - if (!desk) ListTile( - title: Text(tr('화면 방향')), - subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')), - trailing: DropdownButton( - value: s.screenOrientation, - items: [ - DropdownMenuItem(value: 'landscape', child: Text(tr('가로 고정 (기본)'))), - DropdownMenuItem(value: 'portrait', child: Text(tr('세로 고정'))), - DropdownMenuItem(value: 'auto', child: Text(tr('자동'))), - ], - onChanged: (v) { - c.updateSettings((x) => x.screenOrientation = v!); - applyScreenOrientation(v!); - }, - ), - ), - ListTile( - title: Text(tr('기본 화면 크기')), - subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(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 ? tr(' (보통)') : ''}')), - // 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(tr('시작 · 웹 브라우저')), - ListTile( - title: Text(tr('홈 화면')), - subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')), - trailing: DropdownButton( - value: s.startScreen, - items: [ - DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))), - DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.startScreen = v!), - ), - ), - _textTile(tr('홈 주소'), s.homeUrl, tr('웹 브라우저의 시작 · 🏠 주소'), - (v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())), - SwitchListTile( - value: s.youtubeAdSkip, - onChanged: (v) => c.updateSettings((x) => x.youtubeAdSkip = v), - title: Text(tr('YouTube 광고 자동 건너뛰기')), - subtitle: Text(tr('앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 ' '(넘기는 동안 화면 위쪽에 "광고 건너뛰는 중…")')), - ), - SwitchListTile( - value: s.youtubeAdHide, - onChanged: (v) => c.updateSettings((x) => x.youtubeAdHide = v), - title: Text(tr('YouTube 광고 배너 숨기기')), - subtitle: Text(tr('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')), - ), - if (desk) ...[ - ListTile( - title: Text(tr('브라우저 엔진 (앱 안)')), - subtitle: Text( - trf('Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\n' 'Chrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 ' '(Google 로그인은 막힐 수 있음).' '{1}' '{2}', [CefRuntime.approxDownloadMb, CefRuntime.installed ? trf(' 지금 설치됨 ({0}MB)', [CefRuntime.installedMb()]) : '', s.browserEngine == 'chrome' && !CefRuntime.readyThisRun ? tr(' · 다시 시작해야 Chrome 으로 바뀝니다') : ''])), - isThreeLine: true, - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - if (s.browserEngine == 'edge' && CefRuntime.installed && !CefRuntime.readyThisRun) - IconButton( - tooltip: tr('내려받은 Chrome 엔진 지우기'), - icon: const Icon(Icons.delete_outline, color: JjColors.textDim), - onPressed: () async { - final ok = await CefRuntime.uninstall(); - c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).')); - setState(() {}); + title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), + subtitle: Text(s.ytCookiesFile.isNotEmpty + ? 'cookies.txt: ${s.ytCookiesFile}' + : s.ytCookiesBrowser == internalBrowserCookies + ? tr('앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)') + : s.ytCookiesBrowser.isNotEmpty + ? trf('{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)', [s.ytCookiesBrowser]) + : tr('사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요')), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + DropdownButton( + value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser, + items: [ + DropdownMenuItem(value: '', child: Text(tr('사용 안 함'))), + DropdownMenuItem(value: internalBrowserCookies, child: Text(tr('앱 안 브라우저 (권장)'))), + // PC 브라우저의 쿠키는 Windows 에서만 + if (desk) + for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera']) + DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))), + DropdownMenuItem(value: '*file', child: Text(tr('cookies.txt 파일…'))), + ], + onChanged: (v) async { + if (v == '*file') { + final f = await FilePicker.pickFile( + dialogTitle: tr('cookies.txt (Netscape 형식)'), + type: FileType.custom, + allowedExtensions: const ['txt']); + if (f?.path != null) { + await c.updateSettings((x) => x + ..ytCookiesFile = f!.path! + ..ytCookiesBrowser = ''); + } + } else { + await c.updateSettings((x) => x + ..ytCookiesBrowser = v ?? '' + ..ytCookiesFile = ''); + } }, ), - DropdownButton( - value: s.browserEngine, - items: [ - DropdownMenuItem(value: 'edge', child: Text(tr('Edge (내장)'))), - DropdownMenuItem( - value: 'chrome', - child: Text(CefRuntime.installed ? tr('Chrome (내장)') : tr('Chrome (내려받기)'))), - ], - onChanged: (v) async { - if (v == 'chrome') { - await chooseChromeEngine(context, c); - if (mounted) setState(() {}); - } else { - await c.updateSettings((x) => x.browserEngine = 'edge'); - } - }, - ), - ]), - ), - ListTile( - title: Text(tr('외부 브라우저')), - subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')), - trailing: DropdownButton( - value: s.externalBrowser, - items: [ - DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 브라우저'))), - for (final b in {...c.services.shell.installedBrowsers(), if (s.externalBrowser != 'system') s.externalBrowser}) - DropdownMenuItem(value: b, child: Text(switch (b) { - 'chrome' => 'Chrome', - 'firefox' => 'Firefox', - 'edge' => 'Edge', - 'whale' => 'Whale', - _ => b, - })), - ], - onChanged: (v) => c.updateSettings((x) => x.externalBrowser = v!), - ), - ), - ], - _section(tr('저장 위치')), - _folderTile( - title: tr('MKV · 자막 (jj_mkv)'), - value: s.mkvOutputRoot, - defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'), - onPick: () async { - final d = await _pickDir(tr('MKV · 자막 저장 위치'), s.mkvOutputRoot); - if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d); - }, - onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null), - ), - _folderTile( - title: tr('이동 폴더'), - value: s.moveTargetDir, - defaultText: tr('정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다'), - onPick: () async { - final d = await _pickDir(tr('이동 폴더'), s.moveTargetDir); - if (d != null) await c.updateSettings((x) => x.moveTargetDir = d); - }, - onReset: () => c.updateSettings((x) => x.moveTargetDir = null), - ), - _folderTile( - title: tr('다운로드 (jj_yt-dlp · jj_aria2)'), - value: s.downloadRoot, - defaultText: trf('{0} (기본): {1}', [desk ? tr('프로그램 폴더') : tr('내장 저장소'), s.resolvedDownloadRoot()]), - onPick: () async { - final d = await _pickDir(tr('다운로드 위치'), s.resolvedDownloadRoot()); - if (d != null) await c.updateSettings((x) => x.downloadRoot = d); - }, - onReset: () => c.updateSettings((x) => x.downloadRoot = null), - ), - if (desk) _section(tr('다운로드')), - if (desk) - SwitchListTile( - value: s.clipboardWatch, - onChanged: (v) => c.updateSettings((x) => x.clipboardWatch = v), - title: Text(tr('복사(Ctrl+C)한 주소 자동 다운로드')), - subtitle: Text(tr('YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)')), - ), - _section(tr('재생')), - ListTile( - title: Text(tr('동영상 하나를 재생할 때')), - subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')), - trailing: DropdownButton( - value: s.playlistMode, - items: [ - for (final m in PlaylistMode.values) DropdownMenuItem(value: m, child: Text(m.label)), - ], - onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), - ), - ), - ListTile( - title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')), - subtitle: Text(desk - ? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)') - : tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')), - trailing: DropdownButton( - value: s.openFileAction, - items: [ - DropdownMenuItem(value: 'play', child: Text(tr('바로 재생 (기본)'))), - DropdownMenuItem(value: 'add', child: Text(tr('편집 목록에 추가 (MKV 만들기 대상)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!), - ), - ), - if (desk) ...[ - ListTile( - title: Text(tr('탐색기에서 연 동영상을 재생할 창')), - subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')), - trailing: DropdownButton( - value: s.openFileWindow, - items: [ - DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))), - DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))), - ], - onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!), - ), - ), - _ExternalPlayers(c: c), - _ContextMenuTile(c: c), - ], - _section(tr('인터넷 자막 (OpenSubtitles.com)')), - _textTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, - tr('가입 → 프로필 → API consumers → New consumer'), - (v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())), - _textTile(tr('아이디 (선택)'), s.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'), - (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), - _textTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0} 의 설정 파일에 저장됩니다', [desk ? 'PC' : tr('기기')]), - (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), - ListTile( - title: Text(tr('YouTube 받을 형식')), - subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')), - trailing: DropdownButton( - value: s.ytContainer, - items: [for (final v in YtContainer.values) DropdownMenuItem(value: v, child: Text(v.label))], - onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!), - ), - ), - ListTile( - enabled: !s.ytContainer.audioOnly, - title: Text(tr('YouTube 화질')), - subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')), - trailing: DropdownButton( - value: s.ytQuality, - items: [for (final v in YtQuality.values) DropdownMenuItem(value: v, child: Text(v.label))], - onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytQuality = v!), - ), - ), - SwitchListTile( - value: s.ytExpandPlaylists, - onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v), - title: Text(tr('재생목록 주소면 목록 전체 받기')), - subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')), - ), - ListTile( - title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), - subtitle: Text(s.ytCookiesFile.isNotEmpty - ? 'cookies.txt: ${s.ytCookiesFile}' - : s.ytCookiesBrowser == internalBrowserCookies - ? tr('앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)') - : s.ytCookiesBrowser.isNotEmpty - ? trf('{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)', [s.ytCookiesBrowser]) - : tr('사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요')), - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - DropdownButton( - value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser, - items: [ - DropdownMenuItem(value: '', child: Text(tr('사용 안 함'))), - DropdownMenuItem(value: internalBrowserCookies, child: Text(tr('앱 안 브라우저 (권장)'))), - // PC 브라우저의 쿠키는 Windows 에서만 - if (desk) - for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera']) - DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))), - DropdownMenuItem(value: '*file', child: Text(tr('cookies.txt 파일…'))), - ], - onChanged: (v) async { - if (v == '*file') { - final f = await FilePicker.pickFile( - dialogTitle: tr('cookies.txt (Netscape 형식)'), - type: FileType.custom, - allowedExtensions: const ['txt']); - if (f?.path != null) { - await c.updateSettings((x) => x - ..ytCookiesFile = f!.path! - ..ytCookiesBrowser = ''); - } - } else { - await c.updateSettings((x) => x - ..ytCookiesBrowser = v ?? '' - ..ytCookiesFile = ''); - } - }, - ), - ]), - ), - ListTile( - title: Text(tr('동시 다운로드 수')), - subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')), - trailing: CountSelector( - value: s.maxParallelDownloads, - onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n), - ), - ), - _section(tr('변환')), - ListTile( - title: Text(tr('동시 MKV 변환 수')), - subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), - trailing: CountSelector( - value: s.maxParallelJobs, - onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), - ), - ), - if (desk) ...[ - _section(tr('실행')), - ListTile( - title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')), - subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])), - isThreeLine: true, - trailing: DropdownButton( - value: s.closeAction, - items: [ - DropdownMenuItem(value: 'background', child: Text(tr('백그라운드로 계속 (기본)'))), - DropdownMenuItem(value: 'quit', child: Text(tr('다운로드 · 변환도 모두 종료 (묻지 않음)'))), - DropdownMenuItem(value: 'ask', child: Text(tr('매번 고르기'))), - ], - onChanged: (v) => c.updateSettings((x) => x.closeAction = v!), - ), - ), - SwitchListTile( - value: s.minimizeToTray, - onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v), - title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')), - subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')), - ), - ListTile( - title: Text(tr('창 보이기 / 숨기기 단축키')), - subtitle: _hotkeyError == null - ? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다')) - : Text(_hotkeyError!, style: const TextStyle(color: JjColors.danger)), - trailing: SizedBox( - width: 260, - child: Row(children: [ - Expanded( - child: TextField( - controller: _hotkey, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - onSubmitted: (_) => _applyHotkey(), - ), - ), - const SizedBox(width: 8), - OutlinedButton(onPressed: _applyHotkey, child: Text(tr('적용'))), ]), ), - ), - SwitchListTile( - value: s.addFinishedDownloads, - onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), - title: Text(tr('완료시 자동 동영상추가')), - subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), - ), - ], - _section(tr('AI 자막')), - SwitchListTile( - value: s.askAiOptions, - onChanged: (v) => c.updateSettings((x) => x.askAiOptions = v), - title: Text(tr('시작할 때마다 설정 창 보기')), - subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])), - ), - _section(tr('프로그램')), - if (desk) ...[ - ListTile( - title: Text(tr('필수 프로그램 점검')), - subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')), - trailing: OutlinedButton( - onPressed: () => checkRequiredTools(context, c.services.shell, quietIfOk: false), - child: Text(tr('점검')), + ListTile( + title: Text(tr('동시 다운로드 수')), + subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')), + trailing: CountSelector( + value: s.maxParallelDownloads, + onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n), + ), ), - ), - ], - ListTile( - title: Text(tr('오픈 소스 라이선스')), - subtitle: Text(tr('포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)')), - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - if (desk) OutlinedButton( - onPressed: () => c.services.shell.openExternal('system', [ - '${File(Platform.resolvedExecutable).parent.path}\\THIRD_PARTY_NOTICES.txt', + _folderTile( + title: tr('다운로드 (jj_yt-dlp · jj_aria2)'), + value: s.downloadRoot, + defaultText: trf('{0} (기본): {1}', [desk ? tr('프로그램 폴더') : tr('내장 저장소'), s.resolvedDownloadRoot()]), + onPick: () async { + final d = await _pickDir(tr('다운로드 위치'), s.resolvedDownloadRoot()); + if (d != null) await c.updateSettings((x) => x.downloadRoot = d); + }, + onReset: () => c.updateSettings((x) => x.downloadRoot = null), + ), + ]), + if (desk) + _group('run', Icons.power_settings_new, tr('실행 · 종료'), [ + ListTile( + title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')), + subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])), + isThreeLine: true, + trailing: DropdownButton( + value: s.closeAction, + items: [ + DropdownMenuItem(value: 'background', child: Text(tr('백그라운드로 계속 (기본)'))), + DropdownMenuItem(value: 'quit', child: Text(tr('다운로드 · 변환도 모두 종료 (묻지 않음)'))), + DropdownMenuItem(value: 'ask', child: Text(tr('매번 고르기'))), + ], + onChanged: (v) => c.updateSettings((x) => x.closeAction = v!), + ), + ), + SwitchListTile( + value: s.minimizeToTray, + onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v), + title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')), + subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')), + ), + ListTile( + title: Text(tr('창 보이기 / 숨기기 단축키')), + subtitle: _hotkeyError == null + ? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다')) + : Text(_hotkeyError!, style: const TextStyle(color: JjColors.danger)), + trailing: SizedBox( + width: 260, + child: Row(children: [ + Expanded( + child: TextField( + controller: _hotkey, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + onSubmitted: (_) => _applyHotkey(), + ), + ), + const SizedBox(width: 8), + OutlinedButton(onPressed: _applyHotkey, child: Text(tr('적용'))), ]), - child: Text(tr('고지 문서')), ), - if (desk) const SizedBox(width: 6), - OutlinedButton( - onPressed: () => showLicensePage(context: context, applicationName: 'JJ_MKVMaker'), - child: Text(tr('패키지 라이선스')), + ), + ]), + _group('about', Icons.info_outline, tr('프로그램 정보'), [ + if (desk) ...[ + ListTile( + title: Text(tr('필수 프로그램 점검')), + subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')), + trailing: OutlinedButton( + onPressed: () => checkRequiredTools(context, c.services.shell, quietIfOk: false), + child: Text(tr('점검')), ), - ]), - ), - _section(tr('인코딩')), - ListTile( - title: Text(tr('마지막 인코딩 설정을 기억합니다')), - subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])), - ), + ), + ], + ListTile( + title: Text(tr('오픈 소스 라이선스')), + subtitle: Text(tr('포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)')), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (desk) OutlinedButton( + onPressed: () => c.services.shell.openExternal('system', [ + '${File(Platform.resolvedExecutable).parent.path}\\THIRD_PARTY_NOTICES.txt', + ]), + child: Text(tr('고지 문서')), + ), + if (desk) const SizedBox(width: 6), + OutlinedButton( + onPressed: () => showLicensePage(context: context, applicationName: 'JJ_MKVMaker'), + child: Text(tr('패키지 라이선스')), + ), + ]), + ), + ]), ], ), ), @@ -684,11 +690,71 @@ class _SettingsPageState extends State { ); } - Widget _section(String t) => Padding( - padding: const EdgeInsets.only(top: 16, bottom: 4), + /// 묶음 이름 → 위치 (바로가기 버튼으로 그 묶음까지 스크롤) + final _groupKeys = {}; + + static const _groupIds = ['general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'download', 'run', 'about']; + + String _groupTitle(String id) => switch (id) { + 'general' => tr('일반'), + 'display' => tr('화면'), + 'mkv' => tr('MKV 만들기'), + 'subtitle' => tr('자막 (AI · 인터넷)'), + 'play' => tr('재생'), + 'browser' => tr('웹 브라우저'), + 'download' => tr('다운로드'), + 'run' => tr('실행 · 종료'), + _ => tr('프로그램 정보'), + }; + + /// 맨 위 바로가기: 묶음 이름을 누르면 그 묶음으로 스크롤 + Widget _groupBar(bool desk) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Wrap(spacing: 8, runSpacing: 8, children: [ + for (final id in _groupIds) + if (id != 'run' || desk) + ActionChip( + label: Text(_groupTitle(id), style: const TextStyle(fontSize: 13)), + onPressed: () { + final ctx = _groupKeys[id]?.currentContext; + if (ctx != null) { + Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + }, + ), + ]), + ); + + /// 묶음 하나: 제목 (아이콘) 과 항목들을 한 카드에 + Widget _group(String id, IconData icon, String title, List children) => Card( + key: _groupKeys.putIfAbsent(id, GlobalKey.new), + margin: const EdgeInsets.only(bottom: 16), + color: JjColors.panel, + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Container( + color: JjColors.panelHigh, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + Icon(icon, size: 20, color: JjColors.accent), + const SizedBox(width: 10), + Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ]), + ), + ...children, + ]), + ), + ); + + /// 묶음 안의 작은 제목 + Widget _subTitle(String t) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Text(t, style: const TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)), ); + Widget _textTile(String title, String value, String hint, ValueChanged onSave, {bool obscure = false}) => ListTile(