424 lines
20 KiB
Dart
424 lines
20 KiB
Dart
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('등록')),
|
|
),
|
|
);
|
|
}
|