JJ_MKVMaker/lib/ui/settings/media_sections.dart

424 lines
21 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.playback.preferredSubtitleLanguage,
onChanged: (v) => c.updateSettings((x) => x.playback.preferredSubtitleLanguage = v),
),
),
SettingTile(
title: Text(tr('동영상을 세 번 누르면')),
subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')),
trailing: DropdownButton<String>(
value: s.mkv.tripleTapAction,
items: [
DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))),
DropdownMenuItem(value: 'none', child: Text(tr('아무것도 안 함'))),
],
onChanged: (v) => c.updateSettings((x) => x.mkv.tripleTapAction = v!),
),
),
// P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기)
SettingTile(
title: Text(tr('만들 MKV 가 이미 있으면')),
subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')),
trailing: DropdownButton<String>(
value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkv.existing) ? s.mkv.existing : '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.mkv.existing = v ?? 'ask'),
),
),
// 15 지적 · 관리자 판단 (10/17): 폰 세로에서 목록 · 자세히 칸 나누기
SettingTile(
title: Text(tr('폰 세로 목록 크기')),
subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')),
trailing: DropdownButton<String>(
value: s.mkv.compactList,
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.mkv.compactList = v ?? 'auto'),
),
),
SettingTile(
title: Text(tr('동시 MKV 변환 수')),
subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')),
trailing: CountSelector(
value: s.mkv.maxParallelJobs,
onChanged: (n) => c.updateSettings((x) => x.mkv.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.mkv.outputRoot,
defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'),
onPick: () async {
final d = await pickFolder(context, tr('MKV · 자막 저장 위치'), s.mkv.outputRoot);
if (d != null) await c.updateSettings((x) => x.mkv.outputRoot = d);
},
onReset: () => c.updateSettings((x) => x.mkv.outputRoot = null),
),
settingsSubTitle(tr('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')),
for (var i = 0; i < s.mkv.moveTargets.length; i++) MoveTargetTile(c: c, index: i, target: s.mkv.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.mkv.moveTargets = [...x.mkv.moveTargets, MoveTarget(name, d)]);
},
icon: const Icon(Icons.add, size: 18),
label: Text(tr('이동 버튼 추가')),
),
const SizedBox(width: 12),
Expanded(
child: Text(
s.mkv.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.mkv.askAiOptions,
onChanged: (v) => c.updateSettings((x) => x.mkv.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.mkv.openSubtitlesKey,
tr('가입 → 프로필 → API consumers → New consumer'),
(v) => c.updateSettings((x) => x.mkv.openSubtitlesKey = v.trim())),
settingsTextTile(tr('아이디 (선택)'), s.mkv.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'),
(v) => c.updateSettings((x) => x.mkv.openSubtitlesUser = v.trim())),
settingsTextTile(tr('비밀번호 (선택)'), s.mkv.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]),
(v) => c.updateSettings((x) => x.mkv.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.remote.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.remote.davExternalOpen = v!),
),
),
SettingTile(
title: Text(tr('동영상 하나를 재생할 때')),
subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')),
trailing: DropdownButton<PlaylistMode>(
value: s.playback.playlistMode,
items: [
for (final m in PlaylistMode.values) DropdownMenuItem(value: m, child: Text(m.label)),
],
onChanged: (m) => c.updateSettings((x) => x.playback.playlistMode = m!),
),
),
SettingTile(
title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')),
subtitle: Text(desk
? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)')
: tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')),
trailing: DropdownButton<String>(
value: s.playback.openFileAction,
items: [
DropdownMenuItem(value: 'play', child: Text(tr('바로 재생 (기본)'))),
DropdownMenuItem(value: 'add', child: Text(tr('편집 목록에 추가 (MKV 만들기 대상)'))),
],
onChanged: (v) => c.updateSettings((x) => x.playback.openFileAction = v!),
),
),
if (desk) ...[
SettingTile(
title: Text(tr('탐색기에서 연 동영상을 재생할 창')),
subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')),
trailing: DropdownButton<String>(
value: s.playback.openFileWindow,
items: [
DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))),
DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))),
],
onChanged: (v) => c.updateSettings((x) => x.playback.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.mkv.moveTargets = f([...x.mkv.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.mkv.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.playback.externalPlayers;
final vlc = c.services.shell.vlcPath;
Future<void> set(String ext, String? v) => c.updateSettings((x) {
if (v == null || v.isEmpty) {
x.playback.externalPlayers.remove(ext);
} else {
x.playback.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('등록')),
),
);
}