설정 B: settings_page (1,697) 를 분야 섹션 모듈로 (→ 282)
- lib/ui/settings/settings_tiles.dart: 작은 제목 · 글 넣는 줄 · 폴더 줄 · CountSelector - general_sections (일반 · 화면 · 앱 아이콘) · media_sections (MKV · 자막 · 재생 · 이동 버튼 · 외부 플레이어 · 오른쪽 클릭 메뉴) - browser_section · files_section (원격 썸네일 캐시 · 읽기 전용 보기 경고) · transfer_sections (Rsync · 다운로드) - app_sections (실행 · 종료 (단축키) · 저장 공간 정리 · 프로그램 정보) - 페이지는 틀 · 잠금 · 분야 막대 · 묶음 카드만, CountSelector 는 그대로 내보냄 - 약속 시험: test/settings_sections_contract_test.dart - 캡처: integration_test/settings_sections_shot_test.dart (ko · en · ja × 412 · 960, 전체) - 나누기 전 판과 픽셀까지 같음 (2회) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_pages
parent
de4ba076b8
commit
d537d1c3e1
|
|
@ -0,0 +1,45 @@
|
|||
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/app/i18n_controller.dart' show i18n;
|
||||
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';
|
||||
|
||||
/// 설정 B: 환경 설정 화면 전체 (모든 분야) 를 실제 글꼴로 3개 언어 × 412 · 960 폭으로 그려 PNG 로 (JJ_SHOT_DIR).
|
||||
/// 나누기 전후 같은지 픽셀로 견준다. 넘침 (RenderFlex overflow) 이 있으면 시험이 실패한다. 설정은 메모리에만 (저장소 없음)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('환경 설정 전체: ko · en · ja × 412 · 960', (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 lang in ['ko', 'en', 'ja']) {
|
||||
await tester.runAsync(() => i18n.apply(lang, save: false));
|
||||
for (final w in [412.0, 960.0]) {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = Size(w, 900);
|
||||
await tester.pumpWidget(MaterialApp(theme: buildTheme(), home: RepaintBoundary(key: key, child: SettingsPage(c: c))));
|
||||
await tester.pumpAndSettle();
|
||||
// 모든 분야가 한 장에 들어오게 화면을 늘린다
|
||||
final scroll = tester.state<ScrollableState>(find.byType(Scrollable).last).position;
|
||||
tester.view.physicalSize = Size(w, (900 + scroll.maxScrollExtent).clamp(900, 30000).toDouble());
|
||||
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_${lang}_${w.toInt()}.png').writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
}
|
||||
await tester.runAsync(() => i18n.apply('ko', save: false));
|
||||
tester.view.reset();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../services/app_shell.dart';
|
||||
import '../setup_dialog.dart';
|
||||
import '../update_dialog.dart';
|
||||
import '../../platform/windows/desktop_shell.dart' show DesktopShell;
|
||||
import '../../platform/windows/start_menu.dart';
|
||||
import '../app_actions.dart';
|
||||
import '../cleanup_dialog.dart';
|
||||
import '../theme.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
import '../license_texts.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정의 분야 - 실행 · 종료 · 저장 공간 정리 · 프로그램 정보. 바꾸면 바로 설정에 저장 (c.updateSettings)
|
||||
|
||||
/// 환경 설정 > 실행 · 종료
|
||||
class RunSettingsSection extends StatefulWidget {
|
||||
const RunSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<RunSettingsSection> createState() => _RunSettingsSectionState();
|
||||
}
|
||||
|
||||
class _RunSettingsSectionState extends State<RunSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
late final _hotkey = TextEditingController(text: c.settings.showHotkey);
|
||||
String? _hotkeyError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hotkey.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _applyHotkey() async {
|
||||
final text = _hotkey.text.trim();
|
||||
if (parseHotkey(text) == null) {
|
||||
setState(() => _hotkeyError = tr('예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)'));
|
||||
return;
|
||||
}
|
||||
final ok = await c.services.shell.setHotkey(text);
|
||||
setState(() => _hotkeyError = ok ? null : tr('이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)'));
|
||||
if (ok) await c.updateSettings((s) => s.showHotkey = text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
if (desk)
|
||||
SwitchListTile(
|
||||
value: s.startMenuShortcut,
|
||||
onChanged: (v) async {
|
||||
await c.updateSettings((x) => x.startMenuShortcut = v);
|
||||
if (v) {
|
||||
final ok = await StartMenu.ensure();
|
||||
final sh = c.services.shell;
|
||||
if (sh is DesktopShell) sh.toastAppReady = ok;
|
||||
} else {
|
||||
await StartMenu.remove();
|
||||
final sh = c.services.shell;
|
||||
if (sh is DesktopShell) sh.toastAppReady = false;
|
||||
}
|
||||
},
|
||||
title: Text(tr('시작 메뉴에 등록')),
|
||||
subtitle: Text(StartMenu.userAreaLocked()
|
||||
? tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다')
|
||||
: tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')),
|
||||
subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])),
|
||||
isThreeLine: true,
|
||||
trailing: DropdownButton<String>(
|
||||
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('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('창 보이기 / 숨기기 단축키')),
|
||||
subtitle: _hotkeyError == null
|
||||
? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다'))
|
||||
: Text(_hotkeyError!, style: 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('적용'))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > 저장 공간 정리
|
||||
class CleanupSettingsSection extends StatefulWidget {
|
||||
const CleanupSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<CleanupSettingsSection> createState() => _CleanupSettingsSectionState();
|
||||
}
|
||||
|
||||
class _CleanupSettingsSectionState extends State<CleanupSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
SettingTile(
|
||||
title: Text(tr('정리 창에서 미리 체크할 것')),
|
||||
subtitle: Wrap(spacing: 6, runSpacing: 4, children: [
|
||||
for (final (id, label) in [
|
||||
('work', tr('작업 임시 파일')),
|
||||
('download', tr('받다 만 다운로드')),
|
||||
('models', tr('받다 만 AI 모델')),
|
||||
('update', tr('업데이트 · 설치하고 남은 파일')),
|
||||
('logs', tr('지난 작업 기록')),
|
||||
])
|
||||
FilterChip(
|
||||
label: Text(label),
|
||||
selected: s.cleanupPrechecked.contains(id),
|
||||
onSelected: (on) => c.updateSettings((x) => x.cleanupPrechecked = [
|
||||
for (final y in x.cleanupPrechecked) if (y != id) y,
|
||||
if (on) id,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('임시 파일 · 남은 조각 정리')),
|
||||
subtitle: Text(tr('작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · '
|
||||
'업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. '
|
||||
'받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다')),
|
||||
isThreeLine: true,
|
||||
trailing: FilledButton.icon(
|
||||
onPressed: () => showCleanup(context, c, AppScope.maybeOf(context)?.downloads),
|
||||
icon: const Icon(Icons.cleaning_services_outlined, size: 18),
|
||||
label: Text(tr('정리…')),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > 프로그램 정보
|
||||
class AboutSettingsSection extends StatefulWidget {
|
||||
const AboutSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<AboutSettingsSection> createState() => _AboutSettingsSectionState();
|
||||
}
|
||||
|
||||
class _AboutSettingsSectionState extends State<AboutSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
// 164: 지금 버전과 [최신 버전 확인] (결과는 버튼 옆에)
|
||||
SettingTile(
|
||||
leading: Icon(Icons.info_outline, color: JjColors.accent),
|
||||
title: FutureBuilder<String>(
|
||||
future: c.services.updater?.currentVersion() ?? Future.value(''),
|
||||
builder: (_, v) => Text(v.data == null || v.data!.isEmpty ? 'JJ_MKVMaker' : 'JJ_MKVMaker v${v.data}'),
|
||||
),
|
||||
trailing: c.services.updater == null ? null : UpdateCheckButton(c: c),
|
||||
),
|
||||
if (desk) ...[
|
||||
SettingTile(
|
||||
title: Text(tr('필수 프로그램 점검')),
|
||||
subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')),
|
||||
trailing: OutlinedButton(
|
||||
onPressed: () => checkRequiredTools(context, c.services.shell, quietIfOk: false),
|
||||
child: Text(tr('점검')),
|
||||
),
|
||||
),
|
||||
],
|
||||
SettingTile(
|
||||
title: Text(tr('오픈 소스 라이선스')),
|
||||
subtitle: Text(tr('포함된 구성 요소와 라이선스 (GPL · LGPL 원문 포함)')),
|
||||
// 64: Android 에서도 (앱 안에서 연다)
|
||||
trailing: Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => showNoticesDocument(context),
|
||||
child: Text(tr('고지 문서')),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => showLicensePage(context: context, applicationName: 'JJ_MKVMaker'),
|
||||
child: Text(tr('패키지 라이선스')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/i18n_controller.dart' show I18nController;
|
||||
import '../../platform/windows/cef_runtime.dart';
|
||||
import '../../platform/windows/elevation.dart';
|
||||
import '../cef_setup.dart';
|
||||
import '../theme.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
import 'settings_tiles.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정의 분야 - 웹 브라우저. 바꾸면 바로 설정에 저장 (c.updateSettings)
|
||||
|
||||
/// 환경 설정 > 웹 브라우저
|
||||
class BrowserSettingsSection extends StatefulWidget {
|
||||
const BrowserSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<BrowserSettingsSection> createState() => _BrowserSettingsSectionState();
|
||||
}
|
||||
|
||||
class _BrowserSettingsSectionState extends State<BrowserSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
settingsTextTile(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('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.webPauseOnLeave,
|
||||
onChanged: (v) => c.updateSettings((x) => x.webPauseOnLeave = v),
|
||||
title: Text(tr('다른 화면으로 가면 페이지 소리 멈춤')),
|
||||
subtitle: Text(tr('끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다')),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.webTranslate,
|
||||
onChanged: (v) => c.updateSettings((x) => x.webTranslate = v),
|
||||
title: Text(tr('웹 페이지 자동 번역')),
|
||||
subtitle: Text(trf(
|
||||
'다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. '
|
||||
'주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.',
|
||||
[I18nController.nativeName(s.uiLanguage)])),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.webJavaScript,
|
||||
onChanged: (v) => c.updateSettings((x) => x.webJavaScript = v),
|
||||
title: Text(tr('JavaScript 사용')),
|
||||
subtitle: Text(desk
|
||||
? tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). '
|
||||
'Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')
|
||||
: tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). '
|
||||
'끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')),
|
||||
),
|
||||
if (desk) ...[
|
||||
SettingTile(
|
||||
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: Icon(Icons.delete_outline, color: JjColors.textDim),
|
||||
onPressed: () async {
|
||||
final ok = await CefRuntime.uninstall();
|
||||
c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).'));
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
DropdownButton<String>(
|
||||
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');
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
// 207: 관리자 권한일 때도 Chrome 엔진 쓰기 (처음 꺼짐, 켤 때 경고)
|
||||
SwitchListTile(
|
||||
value: s.cefWhenElevated,
|
||||
onChanged: (v) async {
|
||||
if (v) {
|
||||
await confirmCefWhenElevated(context, c);
|
||||
} else {
|
||||
await c.updateSettings((x) => x.cefWhenElevated = false);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)')),
|
||||
subtitle: Text(Elevation.elevated
|
||||
? tr('지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.')
|
||||
: tr('관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.')),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('외부 브라우저')),
|
||||
subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')),
|
||||
trailing: DropdownButton<String>(
|
||||
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!),
|
||||
),
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../core/file_ops.dart' show formatSize;
|
||||
import '../../app/settings.dart';
|
||||
import '../copy_sync_settings.dart';
|
||||
import '../sftp_settings.dart';
|
||||
import '../ftp_settings.dart';
|
||||
import '../smb_settings.dart';
|
||||
import '../webdav_settings.dart';
|
||||
import '../explorer_look.dart' show ExplorerStyle;
|
||||
import '../explorer_keys_page.dart';
|
||||
import '../../core/shizuku.dart' show rootSupported, shizukuSupported;
|
||||
import '../theme.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
import '../component_settings.dart';
|
||||
import '../remote_thumb_view.dart' show remoteThumbsOf;
|
||||
import '../hidden_items_dialog.dart';
|
||||
import '../../core/remote_idle.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정의 분야 - 파일 탐색기. 바꾸면 바로 설정에 저장 (c.updateSettings)
|
||||
|
||||
/// 환경 설정 > 파일 탐색기
|
||||
class FilesSettingsSection extends StatefulWidget {
|
||||
const FilesSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<FilesSettingsSection> createState() => _FilesSettingsSectionState();
|
||||
}
|
||||
|
||||
class _FilesSettingsSectionState extends State<FilesSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
if (desk)
|
||||
SwitchListTile(
|
||||
value: s.recycleOnDelete,
|
||||
onChanged: (v) => c.updateSettings((x) => x.recycleOnDelete = v),
|
||||
title: Text(tr('지우면 휴지통으로')),
|
||||
subtitle: Text(tr('켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. '
|
||||
'네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다')),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('누르기')),
|
||||
subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). '
|
||||
'Ctrl + 클릭: 여러 개 고르기')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerClick,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'select', child: Text(desk ? tr('한 번: 선택 · 두 번: 실행 (기본)') : tr('한 번: 선택 · 두 번: 실행'))),
|
||||
DropdownMenuItem(value: 'open', child: Text(desk ? tr('한 번: 바로 실행') : tr('한 번: 바로 실행 (기본)'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!),
|
||||
),
|
||||
),
|
||||
// 82: 창 사이 끌어 놓기 (처음: 묻기)
|
||||
SettingTile(
|
||||
title: Text(tr('끌어 놓기 기본')),
|
||||
subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerDropAction,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'ask', child: Text(tr('묻기 (기본)'))),
|
||||
DropdownMenuItem(value: 'copy', child: Text(tr('복사'))),
|
||||
DropdownMenuItem(value: 'move', child: Text(tr('이동'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerDropAction = v ?? 'ask'),
|
||||
),
|
||||
),
|
||||
// 84: 정렬을 폴더마다 기억 (처음: 끔)
|
||||
SettingTile(
|
||||
title: Text(tr('정렬을 폴더마다 기억')),
|
||||
subtitle: Text(tr('켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다')),
|
||||
trailing: Switch(
|
||||
value: s.explorerSortPerFolder,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerSortPerFolder = v),
|
||||
),
|
||||
),
|
||||
// X-plore 비교 5-2 (사용자 결정 10/21): 시스템 폴더 보기 (Shizuku, 읽기 전용) - Android, 켤 때마다 경고
|
||||
if (shizukuSupported())
|
||||
SettingTile(
|
||||
title: Text(tr('시스템 폴더 보기 (Shizuku · 읽기 전용)')),
|
||||
subtitle: Text(tr('Shizuku 앱이 있으면 Android/data · obb 처럼 막힌 폴더를 읽기만 합니다. 탐색기 위 탭 [시스템 (읽기 전용)]')),
|
||||
trailing: Switch(
|
||||
value: s.shizukuEnabled,
|
||||
onChanged: (v) async {
|
||||
if (v &&
|
||||
!await confirmReadOnlyView(context,
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
title: tr('시스템 폴더 보기를 켤까요?'),
|
||||
body: tr('Shizuku (adb 권한) 로 다른 앱의 폴더 (Android/data · obb) 를 볼 수 있게 됩니다. 그 안에는 다른 앱의 '
|
||||
'대화 · 로그인 정보 같은 개인 데이터가 있을 수 있습니다.'))) {
|
||||
return;
|
||||
}
|
||||
await c.updateSettings((x) => x.shizukuEnabled = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
// X-plore 비교 5-2 (관리자 결정 10/21): 루트 폴더 보기 (su, 읽기 전용) - Shizuku 와 같은 틀
|
||||
if (rootSupported())
|
||||
SettingTile(
|
||||
title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')),
|
||||
subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')),
|
||||
trailing: Switch(
|
||||
value: s.rootEnabled,
|
||||
onChanged: (v) async {
|
||||
if (v &&
|
||||
!await confirmReadOnlyView(context,
|
||||
icon: Icons.security,
|
||||
title: tr('루트 폴더 보기를 켤까요?'),
|
||||
body: tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 '
|
||||
'(대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.'))) {
|
||||
return;
|
||||
}
|
||||
await c.updateSettings((x) => x.rootEnabled = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
// X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 (처음: 끔)
|
||||
SettingTile(
|
||||
title: Text(tr('종류별 기본 정렬')),
|
||||
subtitle: Text(tr('사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름으로. 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억합니다')),
|
||||
trailing: Switch(
|
||||
value: s.explorerSortByKind,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerSortByKind = v),
|
||||
),
|
||||
),
|
||||
// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 · 캐시
|
||||
SettingTile(
|
||||
title: Text(tr('원격 썸네일')),
|
||||
subtitle: Text(tr('WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerRemoteThumbs,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'off', child: Text(tr('받지 않음'))),
|
||||
DropdownMenuItem(value: 'wifi', child: Text(tr('Wi-Fi 에서만 (기본)'))),
|
||||
DropdownMenuItem(value: 'always', child: Text(tr('늘 (모바일 데이터에서도)'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbs = v!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('원격 썸네일 캐시')),
|
||||
subtitle: Text(tr('넘으면 오래 보지 않은 것부터 지웁니다')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: s.explorerRemoteThumbCacheMb,
|
||||
items: [
|
||||
for (final v in {...AppSettings.remoteThumbCacheChoices, s.explorerRemoteThumbCacheMb}.toList()..sort())
|
||||
DropdownMenuItem(value: v, child: Text(v == 200 ? trf('{0}MB (기본)', [v]) : '${v}MB')),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbCacheMb = v!),
|
||||
),
|
||||
),
|
||||
RemoteThumbCacheTile(c: c),
|
||||
// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억)
|
||||
SettingTile(
|
||||
title: Text(tr('폴더 크기')),
|
||||
subtitle: Text(tr('폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. '
|
||||
'길게 누르기 [폴더 크기 계산] 은 늘 됩니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerFolderSize,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'sort', child: Text(tr('크기순 정렬일 때만 (기본)'))),
|
||||
DropdownMenuItem(value: 'always', child: Text(tr('늘 계산'))),
|
||||
DropdownMenuItem(value: 'off', child: Text(tr('계산 안 함'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerFolderSize = v ?? 'sort'),
|
||||
),
|
||||
),
|
||||
// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 단축키 바꾸기 (키보드가 있는 PC 에서만 보임)
|
||||
if (desk)
|
||||
ListTile(
|
||||
title: Text(tr('파일 탐색기 단축키')),
|
||||
subtitle: Text(s.explorerKeyOverrides.isEmpty
|
||||
? tr('스타일대로 (바꾼 것 없음)')
|
||||
: trf('바꾼 것 {0}개', [s.explorerKeyOverrides.length])),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => ExplorerKeysPage.open(context, c),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('스타일')),
|
||||
// 83: 스타일마다 단축키 - PC 에서만 (194: 키보드가 없는 폰 · 태블릿에는 쓸모없는 긴 글, 15 지적)
|
||||
subtitle: Text([
|
||||
tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · '
|
||||
'Total Commander (촘촘한 목록 · [폴더])'),
|
||||
if (desk)
|
||||
tr('단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. '
|
||||
'그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침'),
|
||||
].join('\n')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerStyle,
|
||||
items: [
|
||||
for (final st in ExplorerStyle.values)
|
||||
DropdownMenuItem(value: st.name, child: Text(tr(st.label))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('창 배치')),
|
||||
subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerLayout,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'auto', child: Text(tr('화면 크기 따라'))),
|
||||
DropdownMenuItem(value: 'dual', child: Text(tr('두 창 (기본)'))),
|
||||
DropdownMenuItem(value: 'split', child: Text(tr('폴더 + 파일 목록'))),
|
||||
DropdownMenuItem(value: 'single', child: Text(tr('한 창'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
|
||||
),
|
||||
),
|
||||
// X-plore 비교 e: Android 앱 휴지통 보관 일
|
||||
if (Platform.isAndroid)
|
||||
SettingTile(
|
||||
title: Text(tr('휴지통 보관')),
|
||||
subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14,
|
||||
items: [
|
||||
for (final d in const [0, 3, 7, 14, 30, 60])
|
||||
DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!),
|
||||
),
|
||||
),
|
||||
// X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙
|
||||
SwitchListTile(
|
||||
title: Text(tr('X-plore 스타일: 선택 표시 늘 보이기')),
|
||||
subtitle: Text(tr('끄면 (처음 값) [선택] 을 켰을 때만 보입니다. 다른 스타일은 늘 선택 모드에서만')),
|
||||
value: s.explorerAlwaysMarks,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerAlwaysMarks = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(tr('X-plore 스타일: 옆으로 밀면 창 바꾸기')),
|
||||
subtitle: Text(tr('탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다')),
|
||||
value: s.explorerSwipePanes,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerSwipePanes = v),
|
||||
),
|
||||
// X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일)
|
||||
SwitchListTile(
|
||||
title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')),
|
||||
subtitle: Text(tr('X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)')),
|
||||
value: s.explorerRootsInTree,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerRootsInTree = v),
|
||||
),
|
||||
// X-plore 비교 d: 지금 폴더를 다시 누르면 접기
|
||||
SwitchListTile(
|
||||
title: Text(tr('펼친 폴더를 다시 누르면 접기')),
|
||||
subtitle: Text(tr('처음 값 켬. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)')),
|
||||
value: s.explorerRetapCollapse,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v),
|
||||
),
|
||||
// X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함)
|
||||
SettingTile(
|
||||
title: Text(tr('목록 글자 크기')),
|
||||
subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: s.explorerTextScale,
|
||||
items: [
|
||||
for (var v = AppSettings.explorerTextScaleMin; v <= AppSettings.explorerTextScaleMax; v += 10)
|
||||
DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')),
|
||||
if (s.explorerTextScale % 10 != 0)
|
||||
DropdownMenuItem(value: s.explorerTextScale, child: Text('${s.explorerTextScale}%')),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerTextScale = v!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('줄 간격')),
|
||||
subtitle: Text(tr('파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerRowSpacing,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'compact', child: Text(tr('좁게'))),
|
||||
DropdownMenuItem(value: 'normal', child: Text(tr('보통 (기본)'))),
|
||||
DropdownMenuItem(value: 'wide', child: Text(tr('넓게'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerRowSpacing = v!),
|
||||
),
|
||||
),
|
||||
// X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18)
|
||||
ListTile(
|
||||
title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorerHiddenItems.length])),
|
||||
subtitle: Text(tr('파일 탐색기의 길게 누르기 [목록에서 숨기기] 로 숨긴 것. 파일은 그대로이고 목록에서만 빠집니다')),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
enabled: s.explorerHiddenItems.isNotEmpty,
|
||||
onTap: () => showHiddenItems(context, c),
|
||||
),
|
||||
// X-plore 맞추기 (관리자 지시 10/20): 숨기기 전 확인 창
|
||||
SwitchListTile(
|
||||
title: Text(tr('목록에서 숨기기 전에 묻기')),
|
||||
subtitle: Text(tr('무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다')),
|
||||
value: s.explorerHideConfirm,
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerHideConfirm = v),
|
||||
),
|
||||
// X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳
|
||||
SettingTile(
|
||||
title: Text(tr('한 창일 때 복사 · 이동')),
|
||||
subtitle: Text(tr('숨은 다른 창으로: X-plore 처럼 위에 "갈 곳" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.explorerOnePaneCopy,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'clip', child: Text(tr('담아 두고 붙여넣기 (기본)'))),
|
||||
DropdownMenuItem(value: 'other', child: Text(tr('숨은 다른 창으로'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.explorerOnePaneCopy = v!),
|
||||
),
|
||||
),
|
||||
// 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한
|
||||
CopySyncSettings(c: c),
|
||||
// WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭)
|
||||
WebDavSettings(c: c),
|
||||
// 88: SFTP 서버 (WebDAV 옆 탭)
|
||||
SftpSettings(c: c),
|
||||
// l: FTP · FTPS 서버
|
||||
FtpSettings(c: c),
|
||||
// 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다)
|
||||
if (Platform.isAndroid) SmbSettings(c: c),
|
||||
// 관리자 판단 (10/20): 원격 서버가 멈췄을 때 기다리는 시간
|
||||
SettingTile(
|
||||
leading: const Icon(Icons.hourglass_bottom_outlined),
|
||||
title: Text(tr('원격 서버 응답 기다리기')),
|
||||
subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds,
|
||||
items: [
|
||||
for (final n in RemoteIdle.choices)
|
||||
DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.remoteIdleSeconds = v!),
|
||||
),
|
||||
),
|
||||
ViewerSettings(c: c),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 캐시의 크기 · [캐시 지우기] (다시 보면 다시 받음)
|
||||
class RemoteThumbCacheTile extends StatefulWidget {
|
||||
final AppController c;
|
||||
const RemoteThumbCacheTile({super.key, required this.c});
|
||||
|
||||
@override
|
||||
State<RemoteThumbCacheTile> createState() => _RemoteThumbCacheTileState();
|
||||
}
|
||||
|
||||
class _RemoteThumbCacheTileState extends State<RemoteThumbCacheTile> {
|
||||
int? _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_measure());
|
||||
}
|
||||
|
||||
Future<void> _measure() async {
|
||||
final n = await (await remoteThumbsOf(widget.c)).size();
|
||||
if (mounted) setState(() => _bytes = n);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
title: Text(tr('원격 썸네일 캐시 지우기')),
|
||||
subtitle: Text(_bytes == null ? tr('크기를 재는 중 …') : trf('지금 {0} · 다시 보면 다시 받습니다', [formatSize(_bytes!)])),
|
||||
trailing: TextButton(
|
||||
onPressed: _bytes == null || _bytes == 0
|
||||
? null
|
||||
: () async {
|
||||
await (await remoteThumbsOf(widget.c)).clear();
|
||||
await _measure();
|
||||
},
|
||||
child: Text(tr('캐시 지우기')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// X-plore 비교 5-2: 시스템 폴더 · 루트 보기를 켤 때 경고 (체크해야 [켜기]). 켜면 true
|
||||
Future<bool> confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
var sure = false;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
scrollable: true,
|
||||
icon: Icon(icon, color: JjColors.warning),
|
||||
title: Text(title),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(body),
|
||||
const SizedBox(height: 6),
|
||||
Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: sure,
|
||||
onChanged: (x) => set(() => sure = x ?? false),
|
||||
title: Text(tr('위 내용을 알고 켭니다')),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: sure ? () => Navigator.pop(ctx, true) : null, child: Text(tr('켜기'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return ok == true;
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../services/app_shell.dart';
|
||||
import '../update_dialog.dart';
|
||||
import '../../app/settings.dart';
|
||||
import '../../platform/android/android_shell.dart' show applyScreenOrientation;
|
||||
import '../language_settings.dart';
|
||||
import '../theme.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정의 분야 - 일반 (언어 · 알림 · 앱 아이콘) · 화면. 바꾸면 바로 설정에 저장 (c.updateSettings)
|
||||
|
||||
/// 환경 설정 > 일반
|
||||
class GeneralSettingsSection extends StatefulWidget {
|
||||
const GeneralSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<GeneralSettingsSection> createState() => _GeneralSettingsSectionState();
|
||||
}
|
||||
|
||||
class _GeneralSettingsSectionState extends State<GeneralSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
// 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위)
|
||||
const LanguageSettings(),
|
||||
// 102: 물어 정한 것 · 1차 판단은 모두 여기서 바꿀 수 있다 (기본값 = 그 답)
|
||||
SwitchListTile(
|
||||
value: s.migrationNotice,
|
||||
onChanged: (v) => c.updateSettings((x) => x.migrationNotice = v),
|
||||
title: Text(tr('업데이트로 바뀐 기본값 알림')),
|
||||
subtitle: Text(tr('새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다')),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.rememberPasswords,
|
||||
onChanged: (v) => c.updateSettings((x) => x.rememberPasswords = v),
|
||||
title: Text(tr('비밀번호 기억 (안전 저장소)')),
|
||||
subtitle: Text(tr('WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다')),
|
||||
),
|
||||
AppIconTile(c: c, desk: desk),
|
||||
if (c.services.updater != null) ...[
|
||||
SettingTile(
|
||||
leading: Icon(Icons.system_update_alt, color: JjColors.accent),
|
||||
title: FutureBuilder<String>(
|
||||
future: c.services.updater!.currentVersion(),
|
||||
builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'),
|
||||
),
|
||||
subtitle: Text(tr('GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). '
|
||||
'바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다')),
|
||||
isThreeLine: true,
|
||||
trailing: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
UpdateCheckButton(c: c),
|
||||
FilledButton.icon(
|
||||
onPressed: () => chooseVersion(context, c),
|
||||
icon: const Icon(Icons.system_update_alt, size: 18),
|
||||
label: Text(tr('업데이트 · 버전 고르기')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.autoCheckUpdates,
|
||||
onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
|
||||
title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')),
|
||||
),
|
||||
],
|
||||
SettingTile(
|
||||
title: Text(tr('홈 화면')),
|
||||
subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.startScreen,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))),
|
||||
DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))),
|
||||
DropdownMenuItem(value: 'files', child: Text(tr('파일 탐색기'))),
|
||||
if (s.components.contains('tor') || s.startScreen == 'tor')
|
||||
DropdownMenuItem(value: 'tor', child: Text(tr('Tor 브라우저'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.startScreen = v!),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > 화면
|
||||
class DisplaySettingsSection extends StatefulWidget {
|
||||
const DisplaySettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<DisplaySettingsSection> createState() => _DisplaySettingsSectionState();
|
||||
}
|
||||
|
||||
class _DisplaySettingsSectionState extends State<DisplaySettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
if (!desk)
|
||||
SettingTile(
|
||||
title: Text(tr('화면 방향')),
|
||||
subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.screenOrientation,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))),
|
||||
DropdownMenuItem(value: 'landscape', child: Text(tr('가로 고정'))),
|
||||
DropdownMenuItem(value: 'portrait', child: Text(tr('세로 고정'))),
|
||||
],
|
||||
onChanged: (v) {
|
||||
c.updateSettings((x) => x.screenOrientation = v!);
|
||||
applyScreenOrientation(v!);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('기본 화면 크기')),
|
||||
subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])),
|
||||
trailing: DropdownButton<double>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
// X-plore 비교 5-1 ⑦ (관리자 지시 10/21): 화면 밝기 - 처음 값 어둡게 (예전 그대로), 다 끝날 때까지 "시험"
|
||||
SettingTile(
|
||||
title: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Flexible(child: Text(tr('화면 밝기'))),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(border: Border.all(color: JjColors.warning), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(tr('시험'), style: TextStyle(fontSize: 11, color: JjColors.warning)),
|
||||
),
|
||||
]),
|
||||
subtitle: Text(tr('밝은 화면 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.uiBrightness,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'dark', child: Text(tr('어두운 화면'))),
|
||||
DropdownMenuItem(value: 'light', child: Text(tr('밝은 화면'))),
|
||||
DropdownMenuItem(value: 'system', child: Text(tr('기기 따라'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.uiBrightness = v!),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱 아이콘 고르기: 그림을 누르면 바로 바뀐다
|
||||
class AppIconTile extends StatelessWidget {
|
||||
const AppIconTile({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = appIconOf(c.settings.appIcon);
|
||||
return SettingTile(
|
||||
title: Text(tr('앱 아이콘')),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(desk
|
||||
? tr('창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)')
|
||||
: tr('앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다')),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 10, runSpacing: 10, children: [
|
||||
for (final id in appIconIds)
|
||||
Tooltip(
|
||||
message: id == appIconIds.first ? '${_name(id)} (${tr('기본')})' : _name(id),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: id == now
|
||||
? null
|
||||
: () {
|
||||
c.updateSettings((x) => x.appIcon = id);
|
||||
c.services.shell.setAppIcon(id);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: id == now ? JjColors.accent : Colors.transparent, width: 3),
|
||||
),
|
||||
child: Image.asset(appIconPreviewAsset(id), width: 56, height: 56, filterQuality: FilterQuality.medium),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _name(String id) => switch (id) {
|
||||
'yellow' => tr('노랑'),
|
||||
'black' => tr('검정'),
|
||||
'film_jj' => tr('필름 + JJ'),
|
||||
'film' => tr('필름'),
|
||||
_ => tr('처음 아이콘 (파랑)'),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import 'dart:async';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../core/playlist.dart';
|
||||
import '../../core/subtitle_detector.dart';
|
||||
import '../../app/settings.dart';
|
||||
import '../folder_picker.dart';
|
||||
import '../theme.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
import '../ai_subtitle_models.dart';
|
||||
import '../subtitle_language_picker.dart';
|
||||
import 'settings_tiles.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정의 분야 - MKV 만들기 · 자막 · 재생. 바꾸면 바로 설정에 저장 (c.updateSettings)
|
||||
|
||||
/// 환경 설정 > MKV 만들기
|
||||
class MkvSettingsSection extends StatefulWidget {
|
||||
const MkvSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<MkvSettingsSection> createState() => _MkvSettingsSectionState();
|
||||
}
|
||||
|
||||
class _MkvSettingsSectionState extends State<MkvSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
// 61: 화면 언어와 다른 언어의 자막을 기본으로 원할 수 있다 (예: 화면은 일본어, 자막은 한국어)
|
||||
SettingTile(
|
||||
title: Text(tr('기본 자막 언어')),
|
||||
subtitle: Text(tr('MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막')),
|
||||
trailing: SubtitleLanguagePicker(
|
||||
value: s.preferredSubtitleLanguage,
|
||||
onChanged: (v) => c.updateSettings((x) => x.preferredSubtitleLanguage = v),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('동영상을 세 번 누르면')),
|
||||
subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.tripleTapAction,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))),
|
||||
DropdownMenuItem(value: 'none', child: Text(tr('아무것도 안 함'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!),
|
||||
),
|
||||
),
|
||||
// P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기)
|
||||
SettingTile(
|
||||
title: Text(tr('만들 MKV 가 이미 있으면')),
|
||||
subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkvExisting) ? s.mkvExisting : 'ask',
|
||||
items: [
|
||||
DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))),
|
||||
DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))),
|
||||
DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))),
|
||||
DropdownMenuItem(value: 'rename', child: Text(tr('다른 이름 (이름 (2).mkv)'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.mkvExisting = v ?? 'ask'),
|
||||
),
|
||||
),
|
||||
// 15 지적 · 관리자 판단 (10/17): 폰 세로에서 목록 · 자세히 칸 나누기
|
||||
SettingTile(
|
||||
title: Text(tr('폰 세로 목록 크기')),
|
||||
subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.mkvCompactList,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))),
|
||||
DropdownMenuItem(value: 'small', child: Text(tr('작게'))),
|
||||
DropdownMenuItem(value: 'large', child: Text(tr('크게'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.mkvCompactList = v ?? 'auto'),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('동시 MKV 변환 수')),
|
||||
subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')),
|
||||
trailing: CountSelector(
|
||||
value: s.maxParallelJobs,
|
||||
onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('마지막 인코딩 설정을 기억합니다')),
|
||||
subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])),
|
||||
),
|
||||
settingsFolderTile(
|
||||
title: tr('MKV · 자막 (jj_mkv)'),
|
||||
value: s.mkvOutputRoot,
|
||||
defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'),
|
||||
onPick: () async {
|
||||
final d = await pickFolder(context, tr('MKV · 자막 저장 위치'), s.mkvOutputRoot);
|
||||
if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d);
|
||||
},
|
||||
onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null),
|
||||
),
|
||||
settingsSubTitle(tr('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')),
|
||||
for (var i = 0; i < s.moveTargets.length; i++) MoveTargetTile(c: c, index: i, target: s.moveTargets[i]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Row(children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final d = await pickFolder(context, tr('이동 버튼의 폴더'));
|
||||
if (d == null) return;
|
||||
final name = p.basename(d).isEmpty ? tr('이동') : p.basename(d);
|
||||
await c.updateSettings((x) => x.moveTargets = [...x.moveTargets, MoveTarget(name, d)]);
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: Text(tr('이동 버튼 추가')),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.moveTargets.isEmpty
|
||||
? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다')
|
||||
: tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > 자막 (AI · 인터넷)
|
||||
class SubtitleSettingsSection extends StatefulWidget {
|
||||
const SubtitleSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<SubtitleSettingsSection> createState() => _SubtitleSettingsSectionState();
|
||||
}
|
||||
|
||||
class _SubtitleSettingsSectionState extends State<SubtitleSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
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])),
|
||||
),
|
||||
// 260: 받은 자막 모델 크기 · 지우기
|
||||
settingsSubTitle(tr('받은 AI 자막 모델')),
|
||||
AiSubtitleModels(c: c),
|
||||
settingsSubTitle(tr('인터넷 자막 (OpenSubtitles.com)')),
|
||||
settingsTextTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey,
|
||||
tr('가입 → 프로필 → API consumers → New consumer'),
|
||||
(v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())),
|
||||
settingsTextTile(tr('아이디 (선택)'), s.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'),
|
||||
(v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())),
|
||||
settingsTextTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]),
|
||||
(v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > 재생
|
||||
class PlaySettingsSection extends StatefulWidget {
|
||||
const PlaySettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<PlaySettingsSection> createState() => _PlaySettingsSectionState();
|
||||
}
|
||||
|
||||
class _PlaySettingsSectionState extends State<PlaySettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
SettingTile(
|
||||
title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')),
|
||||
subtitle: Text(tr('다른 앱에는 아이디 · 비밀번호를 넘기지 않습니다. 받아서 열기는 어느 앱에서나 열리고, 주소로 열기는 아이디를 묻는 앱 (VLC 등) 만 열립니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.davExternalOpen,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'ask', child: Text(tr('매번 묻기 (기본)'))),
|
||||
DropdownMenuItem(value: 'fetch', child: Text(tr('늘 받아서 열기'))),
|
||||
DropdownMenuItem(value: 'url', child: Text(tr('늘 주소로 열기'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.davExternalOpen = v!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('동영상 하나를 재생할 때')),
|
||||
subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')),
|
||||
trailing: DropdownButton<PlaylistMode>(
|
||||
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!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')),
|
||||
subtitle: Text(desk
|
||||
? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)')
|
||||
: tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')),
|
||||
trailing: DropdownButton<String>(
|
||||
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) ...[
|
||||
SettingTile(
|
||||
title: Text(tr('탐색기에서 연 동영상을 재생할 창')),
|
||||
subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')),
|
||||
trailing: DropdownButton<String>(
|
||||
value: s.openFileWindow,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))),
|
||||
DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!),
|
||||
),
|
||||
),
|
||||
ExternalPlayersTile(c: c),
|
||||
ContextMenuTile(c: c),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 이동 버튼 하나: 표시 이름 (바로 고침) · 폴더 · 순서 올리기 · 폴더 바꾸기 · 지우기
|
||||
class MoveTargetTile extends StatelessWidget {
|
||||
const MoveTargetTile({super.key, required this.c, required this.index, required this.target});
|
||||
|
||||
final AppController c;
|
||||
final int index;
|
||||
final MoveTarget target;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = index, t = target;
|
||||
final s = c.settings;
|
||||
void update(List<MoveTarget> Function(List<MoveTarget>) f) =>
|
||||
c.updateSettings((x) => x.moveTargets = f([...x.moveTargets]));
|
||||
return SettingTile(
|
||||
key: ValueKey('move_${i}_${t.dir}'),
|
||||
leading: Icon(Icons.drive_file_move_outline, color: JjColors.accent),
|
||||
title: TextFormField(
|
||||
initialValue: t.name,
|
||||
decoration: InputDecoration(isDense: true, labelText: tr('표시 이름')),
|
||||
onChanged: (v) => update((l) => l..[i] = t.copyWith(name: v.trim().isEmpty ? tr('이동') : v.trim())),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(t.dir, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
IconButton(
|
||||
tooltip: tr('위로'),
|
||||
icon: const Icon(Icons.arrow_upward, size: 18),
|
||||
onPressed: i == 0 ? null : () => update((l) => l..insert(i - 1, l.removeAt(i))),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final d = await pickFolder(context, tr('이동 버튼의 폴더'), t.dir);
|
||||
if (d != null) update((l) => l..[i] = t.copyWith(dir: d));
|
||||
},
|
||||
child: Text(tr('폴더 선택')),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('이 이동 버튼 지우기'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: s.moveTargets.length > i ? () => update((l) => l..removeAt(i)) : null,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 확장자별 재생 프로그램 (내장 / 기본 프로그램 / VLC / 직접 지정)
|
||||
class ExternalPlayersTile extends StatelessWidget {
|
||||
final AppController c;
|
||||
const ExternalPlayersTile({super.key, required this.c});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final map = c.settings.externalPlayers;
|
||||
final vlc = c.services.shell.vlcPath;
|
||||
Future<void> set(String ext, String? v) => c.updateSettings((x) {
|
||||
if (v == null || v.isEmpty) {
|
||||
x.externalPlayers.remove(ext);
|
||||
} else {
|
||||
x.externalPlayers[ext] = v;
|
||||
}
|
||||
});
|
||||
|
||||
return ExpansionTile(
|
||||
title: Text(tr('확장자별 재생 프로그램')),
|
||||
subtitle: Text(map.isEmpty
|
||||
? tr('모두 내장 플레이어')
|
||||
: map.entries.map((e) => '${e.key}: ${e.value == 'system' ? tr('기본 프로그램') : e.value.split(RegExp(r'[\\/]')).last}').join(', ')),
|
||||
children: [
|
||||
for (final ext in videoExtensions)
|
||||
SettingTile(
|
||||
dense: true,
|
||||
title: Text('.$ext'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: map[ext] ?? '',
|
||||
items: [
|
||||
DropdownMenuItem(value: '', child: Text(tr('내장 플레이어'))),
|
||||
DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 프로그램'))),
|
||||
if (vlc != null) DropdownMenuItem(value: vlc, child: const Text('VLC')),
|
||||
if (map[ext] != null && map[ext] != 'system' && map[ext] != vlc)
|
||||
DropdownMenuItem(value: map[ext], child: Text(map[ext]!.split(RegExp(r'[\\/]')).last)),
|
||||
DropdownMenuItem(value: '*pick', child: Text(tr('프로그램 직접 선택…'))),
|
||||
],
|
||||
onChanged: (v) async {
|
||||
if (v == '*pick') {
|
||||
final r = await FilePicker.pickFile(
|
||||
dialogTitle: trf('.{0} 재생 프로그램 (exe)', [ext]), type: FileType.custom, allowedExtensions: const ['exe']);
|
||||
if (r?.path != null) await set(ext, r!.path);
|
||||
} else {
|
||||
await set(ext, v);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (vlc == null)
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(tr('VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 탐색기 오른쪽 클릭 메뉴 등록 / 해제
|
||||
class ContextMenuTile extends StatefulWidget {
|
||||
final AppController c;
|
||||
const ContextMenuTile({super.key, required this.c});
|
||||
|
||||
@override
|
||||
State<ContextMenuTile> createState() => _ContextMenuTileState();
|
||||
}
|
||||
|
||||
class _ContextMenuTileState extends State<ContextMenuTile> {
|
||||
bool? _registered;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final r = await widget.c.services.shell.isContextMenuRegistered();
|
||||
if (mounted) setState(() => _registered = r);
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
setState(() => _busy = true);
|
||||
final shell = widget.c.services.shell;
|
||||
if (_registered == true) {
|
||||
await shell.unregisterContextMenu();
|
||||
} else {
|
||||
final ok = await shell.registerContextMenu();
|
||||
if (!ok && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('등록 중 일부가 실패했습니다.'))));
|
||||
}
|
||||
}
|
||||
await _refresh();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SettingTile(
|
||||
title: Text(tr('탐색기 오른쪽 클릭 메뉴')),
|
||||
subtitle: Text(_registered == true
|
||||
? tr('등록됨: "JJ_MKVMaker 로 재생" · "JJ_MKVMaker 로 자막 만들기" (Windows 11: 더 많은 옵션 표시)')
|
||||
: tr('동영상 파일을 오른쪽 클릭해 바로 재생하거나 자막을 만들 수 있게 합니다 (현재 사용자만)')),
|
||||
trailing: _registered == null
|
||||
? null
|
||||
: OutlinedButton(
|
||||
onPressed: _busy ? null : _toggle,
|
||||
child: Text(_registered! ? tr('해제') : tr('등록')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../theme.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정 분야들이 같이 쓰는 줄 - 작은 제목 · 글 넣는 줄 · 폴더 고르는 줄 · 개수 고르기 (1 · 5 · 10 · 무한 · 직접)
|
||||
|
||||
/// 묶음 안의 작은 제목
|
||||
Widget settingsSubTitle(String t) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(t, style: TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
|
||||
/// 글 넣는 줄 - 칸을 벗어나거나 Enter · 바꿀 때마다 저장
|
||||
Widget settingsTextTile(String title, String value, String hint, ValueChanged<String> onSave,
|
||||
{bool obscure = false}) =>
|
||||
SettingTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(hint),
|
||||
trailing: SizedBox(
|
||||
width: 320,
|
||||
child: TextFormField(
|
||||
initialValue: value,
|
||||
obscureText: obscure,
|
||||
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||
// 입력칸을 벗어나거나 Enter 를 누르면 저장
|
||||
onFieldSubmitted: onSave,
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onChanged: onSave,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 폴더 고르는 줄 - [폴더 선택] · 고른 것이 있으면 [기본값]
|
||||
Widget settingsFolderTile({
|
||||
required String title,
|
||||
required String? value,
|
||||
required String defaultText,
|
||||
required VoidCallback onPick,
|
||||
required VoidCallback onReset,
|
||||
}) =>
|
||||
SettingTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(value ?? defaultText, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
OutlinedButton(onPressed: onPick, child: Text(tr('폴더 선택'))),
|
||||
if (value != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
TextButton(onPressed: onReset, child: Text(tr('기본값'))),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
|
||||
class CountSelector extends StatelessWidget {
|
||||
final int value;
|
||||
final ValueChanged<int> onChanged;
|
||||
const CountSelector({super.key, required this.value, required this.onChanged});
|
||||
|
||||
static const _presets = [1, 5, 10, 0];
|
||||
static String label(int n) => n <= 0 ? tr('무한') : trf('{0}개', [n]);
|
||||
|
||||
Future<void> _custom(BuildContext context) async {
|
||||
// 목록 메뉴가 완전히 닫힌 뒤 입력 창을 연다
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
if (!context.mounted) return;
|
||||
final n = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (_) => _CountInputDialog(initial: value),
|
||||
);
|
||||
if (n != null && n >= 0) onChanged(n);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = {..._presets, value};
|
||||
return DropdownButton<int>(
|
||||
value: value,
|
||||
items: [
|
||||
for (final n in items) DropdownMenuItem(value: n, child: Text(label(n))),
|
||||
DropdownMenuItem(value: -1, child: Text(tr('직접 입력…'))),
|
||||
],
|
||||
onChanged: (n) => n == -1 ? _custom(context) : onChanged(n!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개수 직접 입력 창 (입력칸은 창이 닫히는 애니메이션이 끝난 뒤 정리)
|
||||
class _CountInputDialog extends StatefulWidget {
|
||||
final int initial;
|
||||
const _CountInputDialog({required this.initial});
|
||||
|
||||
@override
|
||||
State<_CountInputDialog> createState() => _CountInputDialogState();
|
||||
}
|
||||
|
||||
class _CountInputDialogState extends State<_CountInputDialog> {
|
||||
late final _ctrl = TextEditingController(text: widget.initial > 0 ? '${widget.initial}' : '');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('직접 입력')),
|
||||
content: TextField(
|
||||
controller: _ctrl,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(labelText: tr('동시에 처리할 개수 (0 = 무한)'), border: OutlineInputBorder()),
|
||||
onSubmitted: (t) => Navigator.pop(context, int.tryParse(t)),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, int.tryParse(_ctrl.text)), child: Text(tr('확인'))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../core/download_detect.dart';
|
||||
import '../folder_picker.dart';
|
||||
import '../copy_sync_settings.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../setting_tile.dart';
|
||||
import '../browser_page.dart' show deleteExportedCookies;
|
||||
import '../toast_status.dart';
|
||||
import 'settings_tiles.dart';
|
||||
|
||||
// 약속 (설정 B): 환경 설정의 분야 - Rsync · 다운로드. 바꾸면 바로 설정에 저장 (c.updateSettings)
|
||||
|
||||
/// 환경 설정 > Rsync
|
||||
class RsyncSettingsSection extends StatefulWidget {
|
||||
const RsyncSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<RsyncSettingsSection> createState() => _RsyncSettingsSectionState();
|
||||
}
|
||||
|
||||
class _RsyncSettingsSectionState extends State<RsyncSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
CopySyncSettings(c: c, rsync: true),
|
||||
SwitchListTile(
|
||||
value: s.allowInnerToOuter,
|
||||
onChanged: (v) => c.updateSettings((x) => x.allowInnerToOuter = v),
|
||||
title: Text(tr('원본이 대상 안에 있어도 맞추기 (안쪽 → 바깥)')),
|
||||
subtitle: Text(tr('확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다')),
|
||||
),
|
||||
if (desk)
|
||||
SwitchListTile(
|
||||
value: s.syncStopToast,
|
||||
onChanged: (v) => c.updateSettings((x) => x.syncStopToast = v),
|
||||
title: Text(tr('동기화가 멈추면 Windows 알림')),
|
||||
subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')),
|
||||
),
|
||||
// Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음)
|
||||
if (desk && s.syncStopToast) const ToastStatusHint(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 환경 설정 > 다운로드
|
||||
class DownloadSettingsSection extends StatefulWidget {
|
||||
const DownloadSettingsSection({super.key, required this.c, required this.desk});
|
||||
|
||||
final AppController c;
|
||||
|
||||
/// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정
|
||||
final bool desk;
|
||||
|
||||
@override
|
||||
State<DownloadSettingsSection> createState() => _DownloadSettingsSectionState();
|
||||
}
|
||||
|
||||
class _DownloadSettingsSectionState extends State<DownloadSettingsSection> {
|
||||
AppController get c => widget.c;
|
||||
bool get desk => widget.desk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = c.settings;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
|
||||
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 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')),
|
||||
),
|
||||
// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 알림 (처음 60초)
|
||||
SettingTile(
|
||||
title: Text(tr('마그넷 · 토렌트가 시작되지 않으면 알리기')),
|
||||
subtitle: Text(tr('메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: s.torrentStallSeconds,
|
||||
items: [
|
||||
for (final v in const [30, 60, 120, 300]) DropdownMenuItem(value: v, child: Text(trf('{0}초', [v]))),
|
||||
DropdownMenuItem(value: 0, child: Text(tr('알리지 않음'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.torrentStallSeconds = v!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('YouTube 받을 형식')),
|
||||
subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')),
|
||||
trailing: DropdownButton<YtContainer>(
|
||||
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!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
enabled: !s.ytContainer.audioOnly,
|
||||
title: Text(tr('YouTube 화질')),
|
||||
subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')),
|
||||
trailing: DropdownButton<YtQuality>(
|
||||
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!),
|
||||
),
|
||||
),
|
||||
// 36: 같은 크기면 H.264 먼저 (Android 기본 켜짐)
|
||||
SwitchListTile(
|
||||
value: s.ytPreferH264,
|
||||
onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytPreferH264 = v),
|
||||
title: Text(tr('같은 화질이면 H.264 먼저 받기')),
|
||||
subtitle: Text(tr('휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. '
|
||||
'YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.')),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.ytExpandPlaylists,
|
||||
onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v),
|
||||
title: Text(tr('재생목록 주소면 목록 전체 받기')),
|
||||
subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')),
|
||||
),
|
||||
SettingTile(
|
||||
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<String>(
|
||||
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 = '');
|
||||
}
|
||||
// 131: 앱 안 브라우저 로그인을 더 쓰지 않으면 내보내 둔 평문 쿠키 파일을 지운다
|
||||
if (c.settings.ytCookiesBrowser != internalBrowserCookies) {
|
||||
await deleteExportedCookies(c.settings.webViewDataDir);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
// 53: 앱 안 브라우저 쿠키 - 어디에 어떻게 저장되는지 · 넘길 사이트 고르기 · 지우기
|
||||
// 131: 쓰지 않게 바꿨어도 파일이 남아 있으면 지우는 버튼은 보인다
|
||||
if (s.ytCookiesBrowser == internalBrowserCookies || s.internalCookieFile.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). '
|
||||
'이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'),
|
||||
// 관리자 지시 (10/21, 131 확인): 12px 흐린 회색은 읽기 힘들었다 - 위아래 설명 글 (ListTile 부제목) 과 같은 크기 · 색으로
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final d in loginCookieDomains)
|
||||
FilterChip(
|
||||
label: Text(d),
|
||||
selected: s.loginCookieSites.contains(d),
|
||||
onSelected: (on) => c.updateSettings((x) => x.loginCookieSites = [
|
||||
for (final y in x.loginCookieSites) if (y != d) y,
|
||||
if (on) d,
|
||||
]),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.cookie_outlined, size: 18),
|
||||
label: Text(tr('쿠키 파일 지우기')),
|
||||
onPressed: () async {
|
||||
await deleteExportedCookies(s.webViewDataDir);
|
||||
if (!context.mounted) return;
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(tr('내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 '
|
||||
'웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.'))));
|
||||
},
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('동시 다운로드 수')),
|
||||
subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')),
|
||||
trailing: CountSelector(
|
||||
value: s.maxParallelDownloads,
|
||||
onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n),
|
||||
),
|
||||
),
|
||||
settingsFolderTile(
|
||||
title: tr('다운로드 (jj_yt-dlp · jj_aria2)'),
|
||||
value: s.downloadRoot,
|
||||
defaultText: trf('{0} (기본): {1}', [desk ? tr('프로그램 폴더') : tr('내장 저장소'), s.resolvedDownloadRoot()]),
|
||||
onPick: () async {
|
||||
final d = await pickFolder(context, tr('다운로드 위치'), s.resolvedDownloadRoot());
|
||||
if (d != null) await c.updateSettings((x) => x.downloadRoot = d);
|
||||
},
|
||||
onReset: () => c.updateSettings((x) => x.downloadRoot = null),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/app_sections.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/browser_section.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/files_section.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/general_sections.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/media_sections.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/settings_tiles.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings/transfer_sections.dart';
|
||||
|
||||
/// 설정 B 약속 시험: lib/ui/settings/ 의 분야 · 공용 줄 (실제 화면 · 실제 설정 - 저장소 없음)
|
||||
void main() {
|
||||
AppController app() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
|
||||
Future<void> show(WidgetTester t, Widget w) async {
|
||||
t.view.physicalSize = const Size(1200, 4000);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: w))));
|
||||
await t.pump();
|
||||
}
|
||||
|
||||
final sections = <String, Widget Function(AppController c, bool desk)>{
|
||||
'general': (c, d) => GeneralSettingsSection(c: c, desk: d),
|
||||
'display': (c, d) => DisplaySettingsSection(c: c, desk: d),
|
||||
'mkv': (c, d) => MkvSettingsSection(c: c, desk: d),
|
||||
'subtitle': (c, d) => SubtitleSettingsSection(c: c, desk: d),
|
||||
'play': (c, d) => PlaySettingsSection(c: c, desk: d),
|
||||
'browser': (c, d) => BrowserSettingsSection(c: c, desk: d),
|
||||
'files': (c, d) => FilesSettingsSection(c: c, desk: d),
|
||||
'rsync': (c, d) => RsyncSettingsSection(c: c, desk: d),
|
||||
'download': (c, d) => DownloadSettingsSection(c: c, desk: d),
|
||||
'run': (c, d) => RunSettingsSection(c: c, desk: d),
|
||||
'cleanup': (c, d) => CleanupSettingsSection(c: c, desk: d),
|
||||
'about': (c, d) => AboutSettingsSection(c: c, desk: d),
|
||||
};
|
||||
for (final e in sections.entries) {
|
||||
testWidgets('분야 ${e.key}: PC · Android 갈래 모두 그려짐 (줄이 하나 이상)', (t) async {
|
||||
for (final desk in [true, false]) {
|
||||
await show(t, e.value(app(), desk));
|
||||
expect(find.byType(ListTile).evaluate().length + find.byType(SwitchListTile).evaluate().length + find.byType(Text).evaluate().length,
|
||||
greaterThan(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('일반: 스위치를 바꾸면 바로 설정에 (업데이트로 바뀐 기본값 알림)', (t) async {
|
||||
final c = app();
|
||||
final before = c.settings.migrationNotice;
|
||||
await show(t, GeneralSettingsSection(c: c, desk: true));
|
||||
await t.tap(find.widgetWithText(SwitchListTile, '업데이트로 바뀐 기본값 알림'));
|
||||
await t.pump();
|
||||
expect(c.settings.migrationNotice, !before);
|
||||
});
|
||||
|
||||
testWidgets('공용 줄: 개수 고르기 · 글 넣는 줄 저장 · 폴더 줄 [기본값] 은 고른 것이 있을 때만', (t) async {
|
||||
int? count;
|
||||
final saved = <String>[];
|
||||
var reset = 0;
|
||||
await show(
|
||||
t,
|
||||
Column(children: [
|
||||
CountSelector(value: 1, onChanged: (n) => count = n),
|
||||
settingsTextTile('이름', '', '설명', saved.add),
|
||||
settingsFolderTile(title: '폴더 A', value: null, defaultText: '처음 값', onPick: () {}, onReset: () => reset++),
|
||||
settingsFolderTile(title: '폴더 B', value: r'D:\x', defaultText: '처음 값', onPick: () {}, onReset: () => reset++),
|
||||
]));
|
||||
await t.tap(find.text(CountSelector.label(1)));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.text(CountSelector.label(5)).last);
|
||||
await t.pumpAndSettle();
|
||||
await t.enterText(find.byType(TextFormField), 'abc');
|
||||
expect([count, saved.last], [5, 'abc']);
|
||||
expect(find.text('기본값'), findsOneWidget, reason: '고른 것이 있는 폴더 줄만');
|
||||
await t.tap(find.text('기본값'));
|
||||
expect(reset, 1);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue