환경 설정: 묶음별 정리 (일반 · 화면 · MKV 만들기 · 자막 · 재생 · 웹 브라우저 · 다운로드 · 실행 · 정보) + 맨 위 바로가기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
480209b45d
commit
6184b6ea71
|
|
@ -435,6 +435,7 @@
|
||||||
"실패: {0}": "Failed: {0}",
|
"실패: {0}": "Failed: {0}",
|
||||||
"실패: {0}\n{1}": "Failed: {0}\n{1}",
|
"실패: {0}\n{1}": "Failed: {0}\n{1}",
|
||||||
"실행": "Run",
|
"실행": "Run",
|
||||||
|
"실행 · 종료": "Run · quit",
|
||||||
"실행 취소": "Undo",
|
"실행 취소": "Undo",
|
||||||
"싱크": "Sync",
|
"싱크": "Sync",
|
||||||
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)",
|
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)",
|
||||||
|
|
@ -558,12 +559,14 @@
|
||||||
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
|
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
|
||||||
"인터넷 자막 찾기": "Find subtitles online",
|
"인터넷 자막 찾기": "Find subtitles online",
|
||||||
"인터넷 자막 찾기{0}": "Find subtitles online{0}",
|
"인터넷 자막 찾기{0}": "Find subtitles online{0}",
|
||||||
|
"일반": "General",
|
||||||
"일본어": "Japanese",
|
"일본어": "Japanese",
|
||||||
"일시정지": "Pause",
|
"일시정지": "Pause",
|
||||||
"자동": "Auto",
|
"자동": "Auto",
|
||||||
"자동 감지": "Auto-detect",
|
"자동 감지": "Auto-detect",
|
||||||
"자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "Automatic installation is not possible here. Please download it from the page yourself.",
|
"자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "Automatic installation is not possible here. Please download it from the page yourself.",
|
||||||
"자막": "Subtitles",
|
"자막": "Subtitles",
|
||||||
|
"자막 (AI · 인터넷)": "Subtitles (AI · online)",
|
||||||
"자막 (S)": "Subtitles (S)",
|
"자막 (S)": "Subtitles (S)",
|
||||||
"자막 {0}개": "{0} subtitles",
|
"자막 {0}개": "{0} subtitles",
|
||||||
"자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "Downloaded {1} of {0} subtitles. Check the job log below.",
|
"자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "Downloaded {1} of {0} subtitles. Check the job log below.",
|
||||||
|
|
@ -748,6 +751,7 @@
|
||||||
"푸는 중": "Extracting",
|
"푸는 중": "Extracting",
|
||||||
"프랑스어": "French",
|
"프랑스어": "French",
|
||||||
"프로그램": "Program",
|
"프로그램": "Program",
|
||||||
|
"프로그램 정보": "About",
|
||||||
"프로그램 정보 · 업데이트": "About · updates",
|
"프로그램 정보 · 업데이트": "About · updates",
|
||||||
"프로그램 직접 선택…": "Choose a program…",
|
"프로그램 직접 선택…": "Choose a program…",
|
||||||
"프로그램 폴더": "Program folder",
|
"프로그램 폴더": "Program folder",
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,7 @@
|
||||||
"실패: {0}": "失敗: {0}",
|
"실패: {0}": "失敗: {0}",
|
||||||
"실패: {0}\n{1}": "失敗: {0}\n{1}",
|
"실패: {0}\n{1}": "失敗: {0}\n{1}",
|
||||||
"실행": "実行",
|
"실행": "実行",
|
||||||
|
"실행 · 종료": "実行・終了",
|
||||||
"실행 취소": "元に戻す",
|
"실행 취소": "元に戻す",
|
||||||
"싱크": "同期",
|
"싱크": "同期",
|
||||||
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)",
|
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)",
|
||||||
|
|
@ -558,12 +559,14 @@
|
||||||
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
|
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
|
||||||
"인터넷 자막 찾기": "インターネットで字幕を探す",
|
"인터넷 자막 찾기": "インターネットで字幕を探す",
|
||||||
"인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}",
|
"인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}",
|
||||||
|
"일반": "一般",
|
||||||
"일본어": "日本語",
|
"일본어": "日本語",
|
||||||
"일시정지": "一時停止",
|
"일시정지": "一時停止",
|
||||||
"자동": "自動",
|
"자동": "自動",
|
||||||
"자동 감지": "自動検出",
|
"자동 감지": "自動検出",
|
||||||
"자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "自動インストールできない場所です。ページから直接ダウンロードしてください。",
|
"자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "自動インストールできない場所です。ページから直接ダウンロードしてください。",
|
||||||
"자막": "字幕",
|
"자막": "字幕",
|
||||||
|
"자막 (AI · 인터넷)": "字幕(AI・インターネット)",
|
||||||
"자막 (S)": "字幕(S)",
|
"자막 (S)": "字幕(S)",
|
||||||
"자막 {0}개": "字幕 {0}個",
|
"자막 {0}개": "字幕 {0}個",
|
||||||
"자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "字幕 {0}個中 {1}個をダウンロードしました。下の作業記録を確認してください。",
|
"자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "字幕 {0}個中 {1}個をダウンロードしました。下の作業記録を確認してください。",
|
||||||
|
|
@ -748,6 +751,7 @@
|
||||||
"푸는 중": "展開中",
|
"푸는 중": "展開中",
|
||||||
"프랑스어": "フランス語",
|
"프랑스어": "フランス語",
|
||||||
"프로그램": "プログラム",
|
"프로그램": "プログラム",
|
||||||
|
"프로그램 정보": "プログラム情報",
|
||||||
"프로그램 정보 · 업데이트": "プログラム情報・更新",
|
"프로그램 정보 · 업데이트": "プログラム情報・更新",
|
||||||
"프로그램 직접 선택…": "プログラムを直接選択…",
|
"프로그램 직접 선택…": "プログラムを直接選択…",
|
||||||
"프로그램 폴더": "プログラムフォルダー",
|
"프로그램 폴더": "プログラムフォルダー",
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,7 @@
|
||||||
"실패: {0}": "失败: {0}",
|
"실패: {0}": "失败: {0}",
|
||||||
"실패: {0}\n{1}": "失败: {0}\n{1}",
|
"실패: {0}\n{1}": "失败: {0}\n{1}",
|
||||||
"실행": "运行",
|
"실행": "运行",
|
||||||
|
"실행 · 종료": "运行 · 退出",
|
||||||
"실행 취소": "撤销",
|
"실행 취소": "撤销",
|
||||||
"싱크": "同步",
|
"싱크": "同步",
|
||||||
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)",
|
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)",
|
||||||
|
|
@ -558,12 +559,14 @@
|
||||||
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
|
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
|
||||||
"인터넷 자막 찾기": "在线查找字幕",
|
"인터넷 자막 찾기": "在线查找字幕",
|
||||||
"인터넷 자막 찾기{0}": "在线查找字幕{0}",
|
"인터넷 자막 찾기{0}": "在线查找字幕{0}",
|
||||||
|
"일반": "常规",
|
||||||
"일본어": "日语",
|
"일본어": "日语",
|
||||||
"일시정지": "暂停",
|
"일시정지": "暂停",
|
||||||
"자동": "自动",
|
"자동": "自动",
|
||||||
"자동 감지": "自动检测",
|
"자동 감지": "自动检测",
|
||||||
"자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "此位置无法自动安装。请从页面手动下载。",
|
"자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "此位置无法自动安装。请从页面手动下载。",
|
||||||
"자막": "字幕",
|
"자막": "字幕",
|
||||||
|
"자막 (AI · 인터넷)": "字幕(AI · 网络)",
|
||||||
"자막 (S)": "字幕(S)",
|
"자막 (S)": "字幕(S)",
|
||||||
"자막 {0}개": "字幕 {0} 个",
|
"자막 {0}개": "字幕 {0} 个",
|
||||||
"자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "已下载 {0} 个字幕中的 {1} 个。请查看下方的任务记录。",
|
"자막 {0}개 중 {1}개를 받았습니다. 아래 작업 기록을 확인하세요.": "已下载 {0} 个字幕中的 {1} 个。请查看下方的任务记录。",
|
||||||
|
|
@ -748,6 +751,7 @@
|
||||||
"푸는 중": "正在解压",
|
"푸는 중": "正在解压",
|
||||||
"프랑스어": "法语",
|
"프랑스어": "法语",
|
||||||
"프로그램": "程序",
|
"프로그램": "程序",
|
||||||
|
"프로그램 정보": "程序信息",
|
||||||
"프로그램 정보 · 업데이트": "程序信息 · 更新",
|
"프로그램 정보 · 업데이트": "程序信息 · 更新",
|
||||||
"프로그램 직접 선택…": "手动选择程序…",
|
"프로그램 직접 선택…": "手动选择程序…",
|
||||||
"프로그램 폴더": "程序文件夹",
|
"프로그램 폴더": "程序文件夹",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -267,414 +267,420 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
children: [
|
children: [
|
||||||
// 맨 위: 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게)
|
// 묶음 바로가기 (누르면 그 묶음으로)
|
||||||
_section('${tr('화면 언어')} · Language'),
|
_groupBar(desk),
|
||||||
const LanguageSettings(),
|
_group('general', Icons.tune, tr('일반'), [
|
||||||
// 지금 버전 · 최신 버전 확인 (Windows · Android)
|
// 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위)
|
||||||
if (c.services.updater != null) ...[
|
const LanguageSettings(),
|
||||||
_section(tr('프로그램 정보 · 업데이트')),
|
if (c.services.updater != null) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.system_update_alt, color: JjColors.accent),
|
leading: const Icon(Icons.system_update_alt, color: JjColors.accent),
|
||||||
title: FutureBuilder<String>(
|
title: FutureBuilder<String>(
|
||||||
future: c.services.updater!.currentVersion(),
|
future: c.services.updater!.currentVersion(),
|
||||||
builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'),
|
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 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다')),
|
SwitchListTile(
|
||||||
trailing: FilledButton.icon(
|
value: s.autoCheckUpdates,
|
||||||
onPressed: () => checkForUpdate(context, c, manual: true),
|
onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')),
|
||||||
label: Text(tr('최신 버전 확인')),
|
),
|
||||||
|
],
|
||||||
|
ListTile(
|
||||||
|
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('웹 브라우저 (홈 주소)'))),
|
||||||
|
],
|
||||||
|
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<String>(
|
||||||
|
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<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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
_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<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!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
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) ...[
|
||||||
|
ListTile(
|
||||||
|
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!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_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<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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
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!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
_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<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!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
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!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: s.autoCheckUpdates,
|
value: s.ytExpandPlaylists,
|
||||||
onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
|
onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v),
|
||||||
title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')),
|
title: Text(tr('재생목록 주소면 목록 전체 받기')),
|
||||||
|
subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
_section(tr('화면')),
|
|
||||||
if (!desk)
|
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(tr('화면 방향')),
|
title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')),
|
||||||
subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')),
|
subtitle: Text(s.ytCookiesFile.isNotEmpty
|
||||||
trailing: DropdownButton<String>(
|
? 'cookies.txt: ${s.ytCookiesFile}'
|
||||||
value: s.screenOrientation,
|
: s.ytCookiesBrowser == internalBrowserCookies
|
||||||
items: [
|
? tr('앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)')
|
||||||
DropdownMenuItem(value: 'landscape', child: Text(tr('가로 고정 (기본)'))),
|
: s.ytCookiesBrowser.isNotEmpty
|
||||||
DropdownMenuItem(value: 'portrait', child: Text(tr('세로 고정'))),
|
? trf('{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)', [s.ytCookiesBrowser])
|
||||||
DropdownMenuItem(value: 'auto', child: Text(tr('자동'))),
|
: tr('사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요')),
|
||||||
],
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
onChanged: (v) {
|
DropdownButton<String>(
|
||||||
c.updateSettings((x) => x.screenOrientation = v!);
|
value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser,
|
||||||
applyScreenOrientation(v!);
|
items: [
|
||||||
},
|
DropdownMenuItem(value: '', child: Text(tr('사용 안 함'))),
|
||||||
),
|
DropdownMenuItem(value: internalBrowserCookies, child: Text(tr('앱 안 브라우저 (권장)'))),
|
||||||
),
|
// PC 브라우저의 쿠키는 Windows 에서만
|
||||||
ListTile(
|
if (desk)
|
||||||
title: Text(tr('기본 화면 크기')),
|
for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera'])
|
||||||
subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])),
|
DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))),
|
||||||
trailing: DropdownButton<double>(
|
DropdownMenuItem(value: '*file', child: Text(tr('cookies.txt 파일…'))),
|
||||||
value: s.uiScaleDefault,
|
],
|
||||||
items: [
|
onChanged: (v) async {
|
||||||
for (var v = AppSettings.uiScaleMin; v <= AppSettings.uiScaleMax + 0.001; v += 0.1)
|
if (v == '*file') {
|
||||||
DropdownMenuItem(
|
final f = await FilePicker.pickFile(
|
||||||
value: AppSettings.clampUiScale(v),
|
dialogTitle: tr('cookies.txt (Netscape 형식)'),
|
||||||
child: Text('${(v * 100).round()}%${(v - 1).abs() < 0.001 ? tr(' (보통)') : ''}')),
|
type: FileType.custom,
|
||||||
// 5% 단위로 저장된 값이 목록에 없을 때
|
allowedExtensions: const ['txt']);
|
||||||
if (((s.uiScaleDefault * 100).round() % 10) != 0)
|
if (f?.path != null) {
|
||||||
DropdownMenuItem(
|
await c.updateSettings((x) => x
|
||||||
value: s.uiScaleDefault, child: Text('${(s.uiScaleDefault * 100).round()}%')),
|
..ytCookiesFile = f!.path!
|
||||||
],
|
..ytCookiesBrowser = '');
|
||||||
// 기본 크기를 바꾸면 지금 크기도 그 크기로
|
}
|
||||||
onChanged: (v) => c.updateSettings((x) => x
|
} else {
|
||||||
..uiScaleDefault = v!
|
await c.updateSettings((x) => x
|
||||||
..uiScale = v),
|
..ytCookiesBrowser = v ?? ''
|
||||||
),
|
..ytCookiesFile = '');
|
||||||
),
|
}
|
||||||
_section(tr('시작 · 웹 브라우저')),
|
|
||||||
ListTile(
|
|
||||||
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('웹 브라우저 (홈 주소)'))),
|
|
||||||
],
|
|
||||||
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(() {});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
_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<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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
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) ...[
|
|
||||||
ListTile(
|
|
||||||
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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_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<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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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<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 = '');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
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<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('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')),
|
|
||||||
),
|
|
||||||
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('적용'))),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
ListTile(
|
||||||
SwitchListTile(
|
title: Text(tr('동시 다운로드 수')),
|
||||||
value: s.addFinishedDownloads,
|
subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')),
|
||||||
onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v),
|
trailing: CountSelector(
|
||||||
title: Text(tr('완료시 자동 동영상추가')),
|
value: s.maxParallelDownloads,
|
||||||
subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')),
|
onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
_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('점검')),
|
|
||||||
),
|
),
|
||||||
),
|
_folderTile(
|
||||||
],
|
title: tr('다운로드 (jj_yt-dlp · jj_aria2)'),
|
||||||
ListTile(
|
value: s.downloadRoot,
|
||||||
title: Text(tr('오픈 소스 라이선스')),
|
defaultText: trf('{0} (기본): {1}', [desk ? tr('프로그램 폴더') : tr('내장 저장소'), s.resolvedDownloadRoot()]),
|
||||||
subtitle: Text(tr('포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)')),
|
onPick: () async {
|
||||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
final d = await _pickDir(tr('다운로드 위치'), s.resolvedDownloadRoot());
|
||||||
if (desk) OutlinedButton(
|
if (d != null) await c.updateSettings((x) => x.downloadRoot = d);
|
||||||
onPressed: () => c.services.shell.openExternal('system', [
|
},
|
||||||
'${File(Platform.resolvedExecutable).parent.path}\\THIRD_PARTY_NOTICES.txt',
|
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<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('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')),
|
||||||
|
),
|
||||||
|
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'),
|
_group('about', Icons.info_outline, tr('프로그램 정보'), [
|
||||||
child: Text(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(
|
||||||
ListTile(
|
title: Text(tr('오픈 소스 라이선스')),
|
||||||
title: Text(tr('마지막 인코딩 설정을 기억합니다')),
|
subtitle: Text(tr('포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)')),
|
||||||
subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])),
|
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<SettingsPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _section(String t) => Padding(
|
/// 묶음 이름 → 위치 (바로가기 버튼으로 그 묶음까지 스크롤)
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 4),
|
final _groupKeys = <String, GlobalKey>{};
|
||||||
|
|
||||||
|
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<Widget> 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)),
|
child: Text(t, style: const TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
Widget _textTile(String title, String value, String hint, ValueChanged<String> onSave,
|
Widget _textTile(String title, String value, String hint, ValueChanged<String> onSave,
|
||||||
{bool obscure = false}) =>
|
{bool obscure = false}) =>
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue