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 createState() => _MkvSettingsSectionState(); } class _MkvSettingsSectionState extends State { 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( 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( 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( 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 createState() => _SubtitleSettingsSectionState(); } class _SubtitleSettingsSectionState extends State { 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 createState() => _PlaySettingsSectionState(); } class _PlaySettingsSectionState extends State { 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( 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( 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( 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( 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 Function(List) 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 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( 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 createState() => _ContextMenuTileState(); } class _ContextMenuTileState extends State { bool? _registered; bool _busy = false; @override void initState() { super.initState(); _refresh(); } Future _refresh() async { final r = await widget.c.services.shell.isContextMenuRegistered(); if (mounted) setState(() => _registered = r); } Future _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('등록')), ), ); }