diff --git a/integration_test/windows_mkvend_shot_test.dart b/integration_test/windows_mkvend_shot_test.dart index 0d5caae..201a962 100644 --- a/integration_test/windows_mkvend_shot_test.dart +++ b/integration_test/windows_mkvend_shot_test.dart @@ -72,7 +72,7 @@ void main() { late Future building; await t.runAsync(() async { await c.init(); - c.settings.maxParallelJobs = 1; + c.settings.mkv.maxParallelJobs = 1; await c.addVideos(files); building = c.buildVideos([...c.videos]); for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { diff --git a/integration_test/windows_mkvportrait_shot_test.dart b/integration_test/windows_mkvportrait_shot_test.dart index 6f852fc..ebc7ff0 100644 --- a/integration_test/windows_mkvportrait_shot_test.dart +++ b/integration_test/windows_mkvportrait_shot_test.dart @@ -46,7 +46,7 @@ void main() { createTranslator: () => throw UnimplementedError(), )); c.settings.display.showLog = true; - c.settings.moveTargets = [MoveTarget('보관', p.join(tmp.path, '보관'))]; + c.settings.mkv.moveTargets = [MoveTarget('보관', p.join(tmp.path, '보관'))]; for (var i = 1; i <= 4; i++) { c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 $i.mp4'))); } diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 069daa4..6c5ca4e 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -167,7 +167,7 @@ class AppController extends ChangeNotifier { SmbRegistry.configure(settings.remote.smbServers); FtpRegistry.configure(settings.remote.ftpServers); outputRootOverride = - (settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; + (settings.mkv.outputRoot?.isNotEmpty ?? false) ? settings.mkv.outputRoot : null; } void _saveSettings() => settingsStore?.save(settings); @@ -199,14 +199,14 @@ class AppController extends ChangeNotifier { try { settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview'); } catch (_) {} - encode = settings.encode; + encode = settings.mkv.encode; aiOptions = AiOptions( - source: languageOf(settings.aiSource), + source: languageOf(settings.mkv.aiSource), targets: { - for (final c in settings.aiTargets) + for (final c in settings.mkv.aiTargets) if (languageOf(c) != undetermined) languageOf(c), }.ifEmpty({for (final c in defaultTargetLanguages) languageOf(c)}), - whisper: whisperModels.firstWhere((m) => m.id == settings.aiWhisper, + whisper: whisperModels.firstWhere((m) => m.id == settings.mkv.aiWhisper, orElse: () => whisperModels.first), ); _applySettings(); @@ -284,7 +284,7 @@ class AppController extends ChangeNotifier { } void _encodeChanged() { - settings.encode = encode; + settings.mkv.encode = encode; _saveSettings(); notifyListeners(); } @@ -888,9 +888,9 @@ class AppController extends ChangeNotifier { if (!aiAvailable || ffmpegVersion == null || targets.isEmpty) return; aiOptions = opts; settings - ..aiSource = opts.source.code - ..aiTargets = [for (final t in opts.targets) t.code] - ..aiWhisper = opts.whisper.id; + ..mkv.aiSource = opts.source.code + ..mkv.aiTargets = [for (final t in opts.targets) t.code] + ..mkv.aiWhisper = opts.whisper.id; _saveSettings(); if (busy) { for (final v in targets) { @@ -1391,7 +1391,7 @@ class AppController extends ChangeNotifier { /// P0: 결과 MKV 가 이미 있을 때 실제로 쓸 동작 ("묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름') String _existingMode(String? asked) { - final m = asked ?? settings.mkvExisting; + final m = asked ?? settings.mkv.existing; return const ['overwrite', 'skip', 'rename'].contains(m) ? m : 'rename'; } @@ -1408,7 +1408,7 @@ class AppController extends ChangeNotifier { final queue = only?.where(videos.contains).toList() ?? videos.where((v) => v.status != JobStatus.done).toList(); if (queue.isEmpty) return; - final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs; + final limit = settings.mkv.maxParallelJobs <= 0 ? queue.length : settings.mkv.maxParallelJobs; if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)])); _batchQueue = queue; _batchNext = 0; diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 2145e23..4e48623 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -4,10 +4,10 @@ import 'package:path/path.dart' as p; import '../core/reader_sources.dart' show defaultImageExtensions; import '../core/download_detect.dart'; -import '../core/encode_options.dart'; import '../core/playlist.dart'; import 'settings/records.dart'; import 'settings/settings_group.dart'; +import 'settings/mkv_settings.dart'; import 'settings/display_settings.dart'; import 'settings/remote_settings.dart'; import 'settings/explorer_tool_settings.dart'; @@ -25,6 +25,7 @@ import 'settings/explorer_settings.dart'; export 'settings/records.dart'; export 'settings/settings_group.dart'; export 'settings/settings_store.dart'; +export 'settings/mkv_settings.dart'; export 'settings/display_settings.dart'; export 'settings/remote_settings.dart'; export 'settings/explorer_tool_settings.dart'; @@ -41,12 +42,7 @@ export 'settings/explorer_settings.dart'; /// 환경 설정 (settings.json 에 저장) class AppSettings { - /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv - String? mkvOutputRoot; - /// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로. - /// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다. - List moveTargets = []; /// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝) List folderBookmarks = []; @@ -59,15 +55,6 @@ class AppSettings { - /// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작) - bool askAiOptions = true; - - - - /// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남) - String openSubtitlesKey = ''; - String openSubtitlesUser = ''; - String openSubtitlesPassword = ''; @@ -75,12 +62,11 @@ class AppSettings { - /// 동시에 만드는 MKV 수 (0 = 무제한) - int maxParallelJobs = 5; - /// 폰 세로 (좁은 화면) MKV 목록 크기: 'auto' 목록은 내용만큼 · 자세히 칸에 최소 높이 (처음 값) · 'small' · 'large' (관리자 판단 10/17) - String mkvCompactList = 'auto'; + + + @@ -150,9 +136,6 @@ class AppSettings { // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── - /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. - /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) - String mkvExisting = 'ask'; @@ -197,8 +180,6 @@ class AppSettings { /// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다) bool recycleOnDelete = true; - /// 47: MKV 목록에서 세 번 누르면 'move' (확인 뒤 이동 폴더로) · 'none' (아무것도 안 함) - String tripleTapAction = 'move'; /// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …) String preferredSubtitleLanguage = ''; @@ -257,13 +238,7 @@ class AppSettings { /// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로 Map externalPlayers = {}; - /// 마지막 인코딩 설정 - EncodeSettings encode = const EncodeSettings(); - /// 마지막 AI 자막 설정 - String aiSource = 'und'; - List aiTargets = ['ko', 'en', 'ja']; - String aiWhisper = 'whisper-base'; AppSettings(); @@ -306,35 +281,21 @@ class AppSettings { /// 화면 · 앱 모양 (배율 · 밝기 · 방향 · 언어 · 앱 아이콘 · 컴포넌트 · 화면 순서 · 밀기) final display = DisplaySettings(); + /// MKV · 자막 만들기 (저장 위치 · 이동 버튼 · 인코딩 · AI 자막 · OpenSubtitles) + final mkv = MkvSettings(); + /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 - List get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display]; + List get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display, mkv]; /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 Map toJson({bool plainSecrets = false}) { final j = { // 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40) ...stripSecrets(extra), - 'mkvOutputRoot': mkvOutputRoot, - 'moveTargets': [for (final t in moveTargets) t.toJson()], 'folderBookmarks': [for (final b in folderBookmarks) b.toJson()], - 'askAiOptions': askAiOptions, - // 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만 - 'openSubtitlesSaved': { - 'key': openSubtitlesKey.isNotEmpty, - 'user': openSubtitlesUser.isNotEmpty, - 'password': openSubtitlesPassword.isNotEmpty, - }, - if (plainSecrets) ...{ - 'openSubtitlesKey': openSubtitlesKey, - 'openSubtitlesUser': openSubtitlesUser, - 'openSubtitlesPassword': openSubtitlesPassword, - }, 'cookieScopeV2': true, - 'maxParallelJobs': maxParallelJobs, - 'mkvCompactList': mkvCompactList, 'openFileAction': openFileAction, 'openFileWindow': openFileWindow, - 'mkvExisting': mkvExisting, 'robocopyLinksV1': true, 'imageExts': imageExts, 'cleanupPrechecked': cleanupPrechecked, @@ -342,28 +303,12 @@ class AppSettings { 'allowInnerToOuter': allowInnerToOuter, 'rememberPasswords': rememberPasswords, 'recycleOnDelete': recycleOnDelete, - 'tripleTapAction': tripleTapAction, 'preferredSubtitleLanguage': preferredSubtitleLanguage, // 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더 'allFilesHint2': allFilesHintShown, 'subtitleScale': subtitleScale, 'playlistMode': playlistMode.name, 'externalPlayers': externalPlayers, - 'encode': { - 'codec': encode.codec.name, - 'resolution': encode.resolution.name, - 'quality': encode.quality.name, - 'frame': encode.frame.name, - 'fit': encode.fit.name, - 'rotate': encode.rotate.name, - 'brightness': encode.brightness, - 'contrast': encode.contrast, - 'saturation': encode.saturation, - 'temperature': encode.temperature, - }, - 'aiSource': aiSource, - 'aiTargets': aiTargets, - 'aiWhisper': aiWhisper, }; for (final g in groups) { g.write(j, plainSecrets: plainSecrets); @@ -371,32 +316,13 @@ class AppSettings { return j; } - - /// 색 보정 값 (-100 ~ 100) - static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); - factory AppSettings.fromJson(Map j) { T pick(List values, Object? name, T fallback) => values.firstWhere((v) => v.name == name, orElse: () => fallback); - final e = (j['encode'] as Map?) ?? const {}; final s = AppSettings() - ..mkvOutputRoot = j['mkvOutputRoot'] as String? ..folderBookmarks = [for (final x in (j['folderBookmarks'] as List?) ?? const []) ?FolderBookmark.fromJson(x)] - ..moveTargets = [ - for (final x in (j['moveTargets'] as List?) ?? const []) - if (x is Map && x['dir'] is String) MoveTarget.fromJson(x), - // 예전 판의 이동 폴더 하나 → "이동" 버튼으로 - if (j['moveTargets'] == null && j['moveTargetDir'] is String) MoveTarget('이동', j['moveTargetDir'] as String), - ] - ..askAiOptions = j['askAiOptions'] as bool? ?? true - ..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? '' - ..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? '' - ..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? '' - ..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5 - ..mkvCompactList = const ['auto', 'small', 'large'].contains(j['mkvCompactList']) ? j['mkvCompactList'] as String : 'auto' ..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play' ..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same' - ..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask' ..imageExts = j['imageExts'] is List ? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()] : [...defaultImageExtensions] @@ -407,27 +333,11 @@ class AppSettings { ..allowInnerToOuter = j['allowInnerToOuter'] != false ..rememberPasswords = j['rememberPasswords'] != false ..recycleOnDelete = j['recycleOnDelete'] != false - ..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move' ..preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : '' ..allFilesHintShown = j['allFilesHint2'] == true ..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series) - ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast() - ..encode = EncodeSettings( - codec: pick(VideoCodecChoice.values, e['codec'], VideoCodecChoice.copy), - resolution: pick(ResolutionChoice.values, e['resolution'], ResolutionChoice.original), - quality: pick(QualityChoice.values, e['quality'], QualityChoice.normal), - frame: pick(FrameChoice.values, e['frame'], FrameChoice.original), - fit: pick(FitChoice.values, e['fit'], FitChoice.fill), - rotate: pick(RotateChoice.values, e['rotate'], RotateChoice.none), - brightness: _adj(e['brightness']), - contrast: _adj(e['contrast']), - saturation: _adj(e['saturation']), - temperature: _adj(e['temperature']), - ) - ..aiSource = j['aiSource'] as String? ?? 'und' - ..aiTargets = (j['aiTargets'] as List?)?.cast() ?? ['ko', 'en', 'ja'] - ..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base'; + ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast(); // 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다 for (final g in s.groups) { g.read(j); diff --git a/lib/app/settings/mkv_settings.dart b/lib/app/settings/mkv_settings.dart new file mode 100644 index 0000000..7ab80a7 --- /dev/null +++ b/lib/app/settings/mkv_settings.dart @@ -0,0 +1,118 @@ +import '../../core/encode_options.dart'; +import 'records.dart'; +import 'settings_group.dart'; + +/// 약속 (설정 A): 환경 설정 > MKV · 자막 만들기 (저장 위치 · 이동 버튼 · 인코딩 · AI 자막 · OpenSubtitles) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다. +/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키) +class MkvSettings implements SettingsGroup { + /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv + String? outputRoot; + + /// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로. + /// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다. + List moveTargets = []; + + /// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작) + bool askAiOptions = true; + + /// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남) + String openSubtitlesKey = ''; + String openSubtitlesUser = ''; + String openSubtitlesPassword = ''; + + /// 동시에 만드는 MKV 수 (0 = 무제한) + int maxParallelJobs = 5; + + /// 폰 세로 (좁은 화면) MKV 목록 크기: 'auto' 목록은 내용만큼 · 자세히 칸에 최소 높이 (처음 값) · 'small' · 'large' (관리자 판단 10/17) + String compactList = 'auto'; + + /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. + /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) + String existing = 'ask'; + + /// 47: MKV 목록에서 세 번 누르면 'move' (확인 뒤 이동 폴더로) · 'none' (아무것도 안 함) + String tripleTapAction = 'move'; + + /// 마지막 인코딩 설정 + EncodeSettings encode = const EncodeSettings(); + + /// 마지막 AI 자막 설정 + String aiSource = 'und'; + List aiTargets = ['ko', 'en', 'ja']; + String aiWhisper = 'whisper-base'; + + /// 색 보정 값 (-100 ~ 100) + static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); + + @override + void write(Map j, {required bool plainSecrets}) { + j['mkvOutputRoot'] = outputRoot; + j['moveTargets'] = [for (final t in moveTargets) t.toJson()]; + j['askAiOptions'] = askAiOptions; + // 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만 + j['openSubtitlesSaved'] = { + 'key': openSubtitlesKey.isNotEmpty, + 'user': openSubtitlesUser.isNotEmpty, + 'password': openSubtitlesPassword.isNotEmpty, + }; + if (plainSecrets) { + j['openSubtitlesKey'] = openSubtitlesKey; + j['openSubtitlesUser'] = openSubtitlesUser; + j['openSubtitlesPassword'] = openSubtitlesPassword; + } + j['maxParallelJobs'] = maxParallelJobs; + j['mkvCompactList'] = compactList; + j['mkvExisting'] = existing; + j['tripleTapAction'] = tripleTapAction; + j['encode'] = { + 'codec': encode.codec.name, + 'resolution': encode.resolution.name, + 'quality': encode.quality.name, + 'frame': encode.frame.name, + 'fit': encode.fit.name, + 'rotate': encode.rotate.name, + 'brightness': encode.brightness, + 'contrast': encode.contrast, + 'saturation': encode.saturation, + 'temperature': encode.temperature, + }; + j['aiSource'] = aiSource; + j['aiTargets'] = aiTargets; + j['aiWhisper'] = aiWhisper; + } + + @override + void read(Map j) { + final e = (j['encode'] as Map?) ?? const {}; + outputRoot = j['mkvOutputRoot'] as String?; + moveTargets = [ + for (final x in (j['moveTargets'] as List?) ?? const []) + if (x is Map && x['dir'] is String) MoveTarget.fromJson(x), + // 예전 판의 이동 폴더 하나 → "이동" 버튼으로 + if (j['moveTargets'] == null && j['moveTargetDir'] is String) MoveTarget('이동', j['moveTargetDir'] as String), + ]; + askAiOptions = j['askAiOptions'] as bool? ?? true; + openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''; + openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''; + openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''; + maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5; + compactList = const ['auto', 'small', 'large'].contains(j['mkvCompactList']) ? j['mkvCompactList'] as String : 'auto'; + existing = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'; + tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move'; + encode = EncodeSettings( + codec: pickEnum(VideoCodecChoice.values, e['codec'], VideoCodecChoice.copy), + resolution: pickEnum(ResolutionChoice.values, e['resolution'], ResolutionChoice.original), + quality: pickEnum(QualityChoice.values, e['quality'], QualityChoice.normal), + frame: pickEnum(FrameChoice.values, e['frame'], FrameChoice.original), + fit: pickEnum(FitChoice.values, e['fit'], FitChoice.fill), + rotate: pickEnum(RotateChoice.values, e['rotate'], RotateChoice.none), + brightness: _adj(e['brightness']), + contrast: _adj(e['contrast']), + saturation: _adj(e['saturation']), + temperature: _adj(e['temperature']), + ); + aiSource = j['aiSource'] as String? ?? 'und'; + aiTargets = (j['aiTargets'] as List?)?.cast() ?? ['ko', 'en', 'ja']; + aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base'; + } +} diff --git a/lib/app/settings/settings_store.dart b/lib/app/settings/settings_store.dart index 78b3efd..17d8521 100644 --- a/lib/app/settings/settings_store.dart +++ b/lib/app/settings/settings_store.dart @@ -54,9 +54,9 @@ class SettingsStore { static const _davServers = 'dav.servers'; static String _os(AppSettings s, String k) => switch (k) { - 'openSubtitlesKey' => s.openSubtitlesKey, - 'openSubtitlesUser' => s.openSubtitlesUser, - _ => s.openSubtitlesPassword, + 'openSubtitlesKey' => s.mkv.openSubtitlesKey, + 'openSubtitlesUser' => s.mkv.openSubtitlesUser, + _ => s.mkv.openSubtitlesPassword, }; /// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움) @@ -109,9 +109,9 @@ class SettingsStore { os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? ''; } s - ..openSubtitlesKey = os['openSubtitlesKey']! - ..openSubtitlesUser = os['openSubtitlesUser']! - ..openSubtitlesPassword = os['openSubtitlesPassword']!; + ..mkv.openSubtitlesKey = os['openSubtitlesKey']! + ..mkv.openSubtitlesUser = os['openSubtitlesUser']! + ..mkv.openSubtitlesPassword = os['openSubtitlesPassword']!; s.remote.webdavServers = [ for (final x in s.remote.webdavServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index 1071e85..4cea341 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -114,9 +114,9 @@ class PlatformServices { usage: Platform.isWindows ? WindowsUsage() : null, createSubtitleProviders: (s) => [ OpenSubtitlesProvider( - apiKey: () => s().openSubtitlesKey, - username: () => s().openSubtitlesUser, - password: () => s().openSubtitlesPassword, + apiKey: () => s().mkv.openSubtitlesKey, + username: () => s().mkv.openSubtitlesUser, + password: () => s().mkv.openSubtitlesPassword, ), ], ); @@ -137,9 +137,9 @@ class PlatformServices { shell: AndroidShell(), createSubtitleProviders: (s) => [ OpenSubtitlesProvider( - apiKey: () => s().openSubtitlesKey, - username: () => s().openSubtitlesUser, - password: () => s().openSubtitlesPassword, + apiKey: () => s().mkv.openSubtitlesKey, + username: () => s().mkv.openSubtitlesUser, + password: () => s().mkv.openSubtitlesPassword, ), ], ); diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index 0c434b4..c5fe0e0 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -41,7 +41,7 @@ Future showAiDialog(BuildContext context, AppController c, VideoItem curre ); // 환경 설정에서 "매번 묻기" 를 끄면 사용자가 고른 대로 묻지 않는다 (마지막 설정으로). // 138: 다만 [MKV 만들기] 와 헷갈려 잘못 누른 것을 되돌릴 수 있게 3초 알림 ([취소]) 뒤에 시작한다. - if (!c.settings.askAiOptions) { + if (!c.settings.mkv.askAiOptions) { final messenger = ScaffoldMessenger.maybeOf(context); if (messenger == null) return run(list, c.aiOptions); var cancelled = false; diff --git a/lib/ui/existing_output_dialog.dart b/lib/ui/existing_output_dialog.dart index 875aa49..5924f39 100644 --- a/lib/ui/existing_output_dialog.dart +++ b/lib/ui/existing_output_dialog.dart @@ -10,7 +10,7 @@ import '../l10n/tr.dart'; /// 돌려주는 것: 'overwrite' · 'skip' · 'rename', 이미 있는 것이 없으면 설정 값 그대로, [취소] 면 null (만들지 않음). /// 예전에는 묻지 않고 시작해, 도중에 취소하면 예전에 완성한 MKV 까지 사라졌다 Future askExistingOutputs(BuildContext context, AppController c, List targets) async { - final mode = c.settings.mkvExisting; + final mode = c.settings.mkv.existing; if (mode != 'ask') return mode; final have = await c.withExistingOutput(targets); if (have.isEmpty || !context.mounted) return 'ask'; diff --git a/lib/ui/explorer_bookmarks.dart b/lib/ui/explorer_bookmarks.dart index fccf4d8..9a13b84 100644 --- a/lib/ui/explorer_bookmarks.dart +++ b/lib/ui/explorer_bookmarks.dart @@ -75,7 +75,7 @@ class _BookmarksDialogState extends State<_BookmarksDialog> { // 바로 가기: 다운로드 폴더 · MKV 결과 폴더 (넣지 않아도) final shortcuts = [ (tr('다운로드'), s.resolvedDownloadRoot()), - if (s.mkvOutputRoot?.isNotEmpty ?? false) (tr('MKV 결과 폴더'), s.mkvOutputRoot!), + if (s.mkv.outputRoot?.isNotEmpty ?? false) (tr('MKV 결과 폴더'), s.mkv.outputRoot!), ].where((x) => !mine.any((b) => !b.isPair && samePath(b.path, x.$2))).toList(); final already = mine.any((b) => !b.isPair && samePath(b.path, widget.current)); final pairAlready = _rsync && mine.any((b) => b.isPair && samePath(b.path, widget.current) && samePath(b.right!, widget.right!)); diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 0560584..40dae7d 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -861,7 +861,7 @@ class _VideoTile extends StatelessWidget { if (!context.mounted) return; if (n == 2) { playFiles(context, c, [v.path]); - } else if (n >= 3 && c.settings.tripleTapAction == 'move') { + } else if (n >= 3 && c.settings.mkv.tripleTapAction == 'move') { // 47: 잘못 세 번 눌러도 파일이 옮겨지지 않게 묻는다 moveToTarget(context, c, [v], confirm: true); } @@ -1003,7 +1003,7 @@ class _CompactBody extends StatelessWidget { Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { final h = box.maxHeight; final picked = c.selected != null; - final cap = switch (c.settings.mkvCompactList) { + final cap = switch (c.settings.mkv.compactList) { 'small' => h * 0.25, 'large' => h * 0.55, // 자동: 자세히 칸 최소 - 고른 동영상이 있으면 62% (세부 정보 · 자막 버튼이 칸 밖으로 밀리지 않게, 261 · 266), @@ -1011,7 +1011,7 @@ class _CompactBody extends StatelessWidget { _ => h - (picked ? h * 0.62 : math.min(120.0, h * 0.4)), }.clamp(math.min(120.0, h), h).toDouble(); // 줄이 많아 내용이 칸보다 확실히 길면 칸 높이 그대로 (shrinkWrap 은 줄을 모두 그려 느리다) - final shrink = c.settings.mkvCompactList == 'auto' && c.videos.length * _rowMin < cap; + final shrink = c.settings.mkv.compactList == 'auto' && c.videos.length * _rowMin < cap; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (shrink) ConstrainedBox(constraints: BoxConstraints(maxHeight: cap), child: _VideoList(c: c, shrink: true)) @@ -1088,7 +1088,7 @@ class _VideoDetail extends StatelessWidget { final moving = c.checked.isEmpty ? [v] : c.batchTargets; final canMove = moving.any((x) => x.status != JobStatus.running); final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; - final buttons = c.settings.moveTargets; + final buttons = c.settings.mkv.moveTargets; Widget button(MoveTarget? t) => Tooltip( message: t == null ? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)') @@ -1538,13 +1538,13 @@ Future moveToTarget(BuildContext context, AppController c, List {MoveTarget? target, bool confirm = false}) async { if (videos.isEmpty) return; final messenger = ScaffoldMessenger.maybeOf(context); - var t = target ?? c.settings.moveTargets.firstOrNull; + var t = target ?? c.settings.mkv.moveTargets.firstOrNull; if (t == null) { final d = await pickFolder(context, tr('이동 폴더 (환경 설정에서 바꿀 수 있음)')); if (d == null) return; t = MoveTarget(p.basename(d).isEmpty ? tr('이동') : p.basename(d), d); final add = t; - await c.updateSettings((x) => x.moveTargets = [...x.moveTargets, add]); + await c.updateSettings((x) => x.mkv.moveTargets = [...x.mkv.moveTargets, add]); } else if (confirm && context.mounted) { final ok = await confirmAction( context, diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index e2a17e2..53d6743 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -146,8 +146,8 @@ Future clearStoredSecrets(AppController c) => c.updateSettings((s) => s ..remote.ftpServers = [for (final x in s.remote.ftpServers) x.copyWith(password: '')] // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) ..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')] - ..openSubtitlesKey = '' - ..openSubtitlesPassword = '').then((_) async { + ..mkv.openSubtitlesKey = '' + ..mkv.openSubtitlesPassword = '').then((_) async { // 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김) await c.settingsStore?.clearForeignSecrets(); }); diff --git a/lib/ui/settings/media_sections.dart b/lib/ui/settings/media_sections.dart index 25f05a8..7da6bd7 100644 --- a/lib/ui/settings/media_sections.dart +++ b/lib/ui/settings/media_sections.dart @@ -50,12 +50,12 @@ class _MkvSettingsSectionState extends State { title: Text(tr('동영상을 세 번 누르면')), subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')), trailing: DropdownButton( - value: s.tripleTapAction, + value: s.mkv.tripleTapAction, items: [ DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))), DropdownMenuItem(value: 'none', child: Text(tr('아무것도 안 함'))), ], - onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!), + onChanged: (v) => c.updateSettings((x) => x.mkv.tripleTapAction = v!), ), ), // P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기) @@ -63,14 +63,14 @@ class _MkvSettingsSectionState extends State { title: Text(tr('만들 MKV 가 이미 있으면')), subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')), trailing: DropdownButton( - value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkvExisting) ? s.mkvExisting : 'ask', + 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.mkvExisting = v ?? 'ask'), + onChanged: (v) => c.updateSettings((x) => x.mkv.existing = v ?? 'ask'), ), ), // 15 지적 · 관리자 판단 (10/17): 폰 세로에서 목록 · 자세히 칸 나누기 @@ -78,21 +78,21 @@ class _MkvSettingsSectionState extends State { title: Text(tr('폰 세로 목록 크기')), subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')), trailing: DropdownButton( - value: s.mkvCompactList, + 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.mkvCompactList = v ?? 'auto'), + onChanged: (v) => c.updateSettings((x) => x.mkv.compactList = 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), + value: s.mkv.maxParallelJobs, + onChanged: (n) => c.updateSettings((x) => x.mkv.maxParallelJobs = n), ), ), SettingTile( @@ -101,16 +101,16 @@ class _MkvSettingsSectionState extends State { ), settingsFolderTile( title: tr('MKV · 자막 (jj_mkv)'), - value: s.mkvOutputRoot, + value: s.mkv.outputRoot, 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); + 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.mkvOutputRoot = null), + onReset: () => c.updateSettings((x) => x.mkv.outputRoot = null), ), settingsSubTitle(tr('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')), - for (var i = 0; i < s.moveTargets.length; i++) MoveTargetTile(c: c, index: i, target: s.moveTargets[i]), + 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: [ @@ -119,7 +119,7 @@ class _MkvSettingsSectionState extends State { 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)]); + await c.updateSettings((x) => x.mkv.moveTargets = [...x.mkv.moveTargets, MoveTarget(name, d)]); }, icon: const Icon(Icons.add, size: 18), label: Text(tr('이동 버튼 추가')), @@ -127,7 +127,7 @@ class _MkvSettingsSectionState extends State { const SizedBox(width: 12), Expanded( child: Text( - s.moveTargets.isEmpty + s.mkv.moveTargets.isEmpty ? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다') : tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim), @@ -161,8 +161,8 @@ class _SubtitleSettingsSectionState extends State { 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), + 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])), ), @@ -170,13 +170,13 @@ class _SubtitleSettingsSectionState extends State { settingsSubTitle(tr('받은 AI 자막 모델')), AiSubtitleModels(c: c), settingsSubTitle(tr('인터넷 자막 (OpenSubtitles.com)')), - settingsTextTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, + settingsTextTile(tr('API 키 (필수, 무료)'), s.mkv.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), + (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), ]); } } @@ -273,7 +273,7 @@ class MoveTargetTile extends StatelessWidget { final i = index, t = target; final s = c.settings; void update(List Function(List) f) => - c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); + 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), @@ -302,7 +302,7 @@ class MoveTargetTile extends StatelessWidget { IconButton( tooltip: tr('이 이동 버튼 지우기'), icon: const Icon(Icons.delete_outline), - onPressed: s.moveTargets.length > i ? () => update((l) => l..removeAt(i)) : null, + onPressed: s.mkv.moveTargets.length > i ? () => update((l) => l..removeAt(i)) : null, ), ]), ); diff --git a/lib/ui/subtitle_search_dialog.dart b/lib/ui/subtitle_search_dialog.dart index cc73cd0..7bb5683 100644 --- a/lib/ui/subtitle_search_dialog.dart +++ b/lib/ui/subtitle_search_dialog.dart @@ -28,7 +28,7 @@ class _SearchDialogState extends State<_SearchDialog> { final _episode = TextEditingController(); final _key = TextEditingController(); late Set _langs = { - for (final code in widget.c.settings.aiTargets) + for (final code in widget.c.settings.mkv.aiTargets) if (languageOf(code) != undetermined) languageOf(code), }; String? _hash; @@ -169,7 +169,7 @@ class _SearchDialogState extends State<_SearchDialog> { FilledButton( onPressed: () async { if (_key.text.trim().isEmpty) return; - await c.updateSettings((s) => s.openSubtitlesKey = _key.text.trim()); + await c.updateSettings((s) => s.mkv.openSubtitlesKey = _key.text.trim()); setState(() {}); _search(); }, diff --git a/test/ai_confirm_test.dart b/test/ai_confirm_test.dart index e742ba5..70b62b0 100644 --- a/test/ai_confirm_test.dart +++ b/test/ai_confirm_test.dart @@ -29,7 +29,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.askAiOptions = false; + c.settings.mkv.askAiOptions = false; final v1 = VideoItem(r'C:\v\a.mp4')..info = const MediaInfo(duration: Duration(minutes: 30)); final v2 = VideoItem(r'C:\v\b.mp4')..info = const MediaInfo(duration: Duration(minutes: 30)); late BuildContext ctx; @@ -61,7 +61,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - expect(c.settings.askAiOptions, isTrue); + expect(c.settings.mkv.askAiOptions, isTrue); final v1 = VideoItem(r'C:\v\a.mp4')..info = const MediaInfo(duration: Duration(hours: 1)); late BuildContext ctx; await tester.pumpWidget(MaterialApp(home: Scaffold(body: Builder(builder: (context) { diff --git a/test/batch_test.dart b/test/batch_test.dart index c60eca0..f4922d0 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -263,7 +263,7 @@ void main() { } final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); - c.settings.maxParallelJobs = 1; + c.settings.mkv.maxParallelJobs = 1; c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); await c.addVideos(files); final [v1, v2, v3] = c.videos; @@ -304,7 +304,7 @@ void main() { } c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); - c.settings.maxParallelJobs = 1; + c.settings.mkv.maxParallelJobs = 1; c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); await c.addVideos(files); return true; @@ -386,7 +386,7 @@ void main() { } final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); - c.settings.maxParallelJobs = 1; + c.settings.mkv.maxParallelJobs = 1; c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); await c.addVideos(files); final [v1, v2, v3] = c.videos; @@ -442,7 +442,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')]; + c.settings.mkv.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')]; c.videos.add(VideoItem(r'D:\v\a.mp4') ..info = const MediaInfo(duration: Duration(minutes: 3), streams: [ StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080), @@ -750,8 +750,8 @@ void main() { final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); c.settings - ..maxParallelJobs = 1 - ..mkvExisting = 'overwrite'; + ..mkv.maxParallelJobs = 1 + ..mkv.existing = 'overwrite'; await c.addVideos(files); final [v1, v2, v3] = c.videos; File(outputMkvPath(v1.path)) @@ -800,7 +800,7 @@ void main() { late Future building; await tester.runAsync(() async { await c.init(); - c.settings.maxParallelJobs = 1; + c.settings.mkv.maxParallelJobs = 1; await c.addVideos(files); building = c.buildVideos([...c.videos]); for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { @@ -850,7 +850,7 @@ void main() { late Future building; await tester.runAsync(() async { await c.init(); - c.settings.maxParallelJobs = 5; + c.settings.mkv.maxParallelJobs = 5; await c.addVideos(files); building = c.buildVideos([...c.videos]); for (var i = 0; i < 200 && tool.runs.length < 2; i++) { diff --git a/test/decisions_test.dart b/test/decisions_test.dart index 96fd32b..38de083 100644 --- a/test/decisions_test.dart +++ b/test/decisions_test.dart @@ -31,7 +31,7 @@ void main() { s.rememberPasswords, s.recycleOnDelete, s.reader.systemBars, ], everyElement(isTrue)); expect(s.cleanupPrechecked, ['work', 'download']); - expect(s.tripleTapAction, 'move'); + expect(s.mkv.tripleTapAction, 'move'); expect(s.remote.davExternalOpen, 'ask'); // 이미 있던 설정의 기본값도 결정대로 expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.display.swipeNav], @@ -49,12 +49,12 @@ void main() { ..rememberPasswords = false ..recycleOnDelete = false ..cleanupPrechecked = ['logs'] - ..tripleTapAction = 'none' + ..mkv.tripleTapAction = 'none' ..remote.davExternalOpen = 'url') .toJson()); expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.display.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, b.recycleOnDelete], everyElement(isFalse)); - expect([b.cleanupPrechecked, b.tripleTapAction, b.remote.davExternalOpen], [['logs'], 'none', 'url']); + expect([b.cleanupPrechecked, b.mkv.tripleTapAction, b.remote.davExternalOpen], [['logs'], 'none', 'url']); }); test('안쪽 → 바깥 허용을 끄면 늘 막는다', () { diff --git a/test/download_test.dart b/test/download_test.dart index e26be3a..2b8c207 100644 --- a/test/download_test.dart +++ b/test/download_test.dart @@ -139,20 +139,20 @@ https://example.com/page.html final store = SettingsStore(p.join(dir.path, 'settings.json')); expect((await store.load()).download.clipboardWatch, isTrue); // 파일 없으면 기본값 final s = AppSettings() - ..mkvOutputRoot = r'D:\out' + ..mkv.outputRoot = r'D:\out' ..download.root = r'D:\dl' ..download.clipboardWatch = false ..run.showHotkey = 'Alt+F9' - ..askAiOptions = false - ..encode = const EncodeSettings(codec: VideoCodecChoice.vp9, resolution: ResolutionChoice.p720) - ..aiTargets = ['ko', 'fr']; + ..mkv.askAiOptions = false + ..mkv.encode = const EncodeSettings(codec: VideoCodecChoice.vp9, resolution: ResolutionChoice.p720) + ..mkv.aiTargets = ['ko', 'fr']; await store.save(s); final l = await store.load(); - expect([l.mkvOutputRoot, l.download.root, l.download.clipboardWatch, l.run.showHotkey, l.askAiOptions], + expect([l.mkv.outputRoot, l.download.root, l.download.clipboardWatch, l.run.showHotkey, l.mkv.askAiOptions], [r'D:\out', r'D:\dl', false, 'Alt+F9', false]); - expect(l.encode.codec, VideoCodecChoice.vp9); - expect(l.encode.resolution, ResolutionChoice.p720); - expect(l.aiTargets, ['ko', 'fr']); + expect(l.mkv.encode.codec, VideoCodecChoice.vp9); + expect(l.mkv.encode.resolution, ResolutionChoice.p720); + expect(l.mkv.aiTargets, ['ko', 'fr']); expect(l.ytDlpDir, p.join(r'D:\dl', 'jj_yt-dlp')); expect(l.aria2Dir, p.join(r'D:\dl', 'jj_aria2')); }); diff --git a/test/master_lock_test.dart b/test/master_lock_test.dart index 260650e..4a75e75 100644 --- a/test/master_lock_test.dart +++ b/test/master_lock_test.dart @@ -191,9 +191,9 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..remote.webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')] - ..openSubtitlesKey = 'k' - ..openSubtitlesUser = 'ou' - ..openSubtitlesPassword = 'op'; + ..mkv.openSubtitlesKey = 'k' + ..mkv.openSubtitlesUser = 'ou' + ..mkv.openSubtitlesPassword = 'op'; return c; } @@ -256,7 +256,7 @@ void main() { expect(find.text('초기화했습니다'), findsOneWidget); expect(c.settings.remote.webdavServers.single.password, ''); expect(c.settings.remote.webdavServers.single.user, 'u'); - expect((c.settings.openSubtitlesKey, c.settings.openSubtitlesPassword, c.settings.openSubtitlesUser), ('', '', 'ou')); + expect((c.settings.mkv.openSubtitlesKey, c.settings.mkv.openSubtitlesPassword, c.settings.mkv.openSubtitlesUser), ('', '', 'ou')); expect(lock.hasMaster, isFalse); }); @@ -283,8 +283,8 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), settingsStore: store); await c.updateSettings((s) => s - ..openSubtitlesKey = 'k' - ..openSubtitlesPassword = 'op' + ..mkv.openSubtitlesKey = 'k' + ..mkv.openSubtitlesPassword = 'op' ..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..remote.sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'PRIVATE KEY', keyPassphrase: 'kp')] ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]); diff --git a/test/mkv_compact_list_test.dart b/test/mkv_compact_list_test.dart index a36ea03..79315f1 100644 --- a/test/mkv_compact_list_test.dart +++ b/test/mkv_compact_list_test.dart @@ -21,7 +21,7 @@ void main() { )); c.settings ..display.showLog = true - ..mkvCompactList = size; + ..mkv.compactList = size; for (var i = 0; i < n; i++) { c.videos.add(VideoItem('D:/v/v$i.mp4')); } diff --git a/test/mkv_portrait_detail_test.dart b/test/mkv_portrait_detail_test.dart index dbc830a..cd366ee 100644 --- a/test/mkv_portrait_detail_test.dart +++ b/test/mkv_portrait_detail_test.dart @@ -23,7 +23,7 @@ void main() { createTranslator: () => throw UnimplementedError(), )); c.settings.display.showLog = true; - c.settings.moveTargets = [MoveTarget('보관', r'D:\보관')]; + c.settings.mkv.moveTargets = [MoveTarget('보관', r'D:\보관')]; for (var i = 1; i <= 4; i++) { c.videos.add(VideoItem('D:/v/아주 긴 이름의 동영상 파일 $i.mp4')); } diff --git a/test/move_test.dart b/test/move_test.dart index 368d492..08b3155 100644 --- a/test/move_test.dart +++ b/test/move_test.dart @@ -142,10 +142,10 @@ void main() { test('이동 버튼 설정 저장 (표시 이름 · 폴더 · 순서), 예전 이동 폴더 하나는 "이동" 버튼으로', () { final s = AppSettings() - ..moveTargets = const [MoveTarget('완료', r'D:\done'), MoveTarget('보관', r'E:\archive')]; - expect(AppSettings.fromJson(s.toJson()).moveTargets, s.moveTargets); - expect(AppSettings().moveTargets, isEmpty); - expect(AppSettings.fromJson({'moveTargetDir': r'D:\old'}).moveTargets, const [MoveTarget('이동', r'D:\old')]); + ..mkv.moveTargets = const [MoveTarget('완료', r'D:\done'), MoveTarget('보관', r'E:\archive')]; + expect(AppSettings.fromJson(s.toJson()).mkv.moveTargets, s.mkv.moveTargets); + expect(AppSettings().mkv.moveTargets, isEmpty); + expect(AppSettings.fromJson({'moveTargetDir': r'D:\old'}).mkv.moveTargets, const [MoveTarget('이동', r'D:\old')]); }); testWidgets('MKV 목록: 세 번 누르면 첫 번째 이동 버튼으로 · 세부 정보의 이동 버튼 (표시 이름) 은 그 폴더로', (tester) async { @@ -155,7 +155,7 @@ void main() { final c = _controller(); final dest = p.join(dir.path, 'done'); final archive = p.join(dir.path, 'archive'); - c.settings.moveTargets = [MoveTarget('완료함', dest), MoveTarget('보관함', archive)]; + c.settings.mkv.moveTargets = [MoveTarget('완료함', dest), MoveTarget('보관함', archive)]; final a = VideoItem(make('a.mp4').path); final b = VideoItem(make('b.mp4').path); final d = VideoItem(make('d.mp4').path); @@ -200,7 +200,7 @@ void main() { } expect(File(p.join(dest, 'b.mp4')).existsSync(), isTrue); // 102: 세 번 누르기를 "아무것도 안 함" 으로 하면 묻지도 옮기지도 않는다 - c.settings.tripleTapAction = 'none'; + c.settings.mkv.tripleTapAction = 'none'; final dTile = find.text('d.mp4').first; for (var i = 0; i < 3; i++) { await tester.tap(dTile); @@ -210,7 +210,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('파일 옮기기'), findsNothing); expect(File(p.join(dest, 'd.mp4')).existsSync(), isFalse); - c.settings.tripleTapAction = 'move'; + c.settings.mkv.tripleTapAction = 'move'; expect(c.videos.map((v) => v.fileName), ['a.mp4', 'd.mp4']); // 세부 정보 오른쪽 아래: 표시 이름 버튼 두 개 (첫 번째가 아래, 두 번째가 그 위) diff --git a/test/queue_test.dart b/test/queue_test.dart index 820f133..33f6546 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -164,7 +164,7 @@ void main() { final tool = _Tool(); final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); - c.settings.maxParallelJobs = limit; + c.settings.mkv.maxParallelJobs = limit; final dir = Directory.systemTemp.createTempSync('jj_par_'); for (var i = 0; i < 5; i++) { final f = File(p.join(dir.path, 'v$i.mp4'))..writeAsStringSync(''); // 없는 파일은 만들지 않으므로 diff --git a/test/secret_store_test.dart b/test/secret_store_test.dart index 0b778f1..bb821f1 100644 --- a/test/secret_store_test.dart +++ b/test/secret_store_test.dart @@ -35,8 +35,8 @@ void main() { 'futureFeature': {'x': 1}, })); final s = await store().load(); - expect(s.openSubtitlesKey, 'KEY-1'); - expect(s.openSubtitlesPassword, 'os-pw'); + expect(s.mkv.openSubtitlesKey, 'KEY-1'); + expect(s.mkv.openSubtitlesPassword, 'os-pw'); expect(s.remote.webdavServers.single.password, 'dav-pw'); expect(secrets.values.values, containsAll(['KEY-1', 'me', 'os-pw', 'dav-pw'])); for (final f in [file, '$file.bak']) { @@ -51,14 +51,14 @@ void main() { // 다시 켜도 그대로 final again = await store().load(); expect(again.remote.webdavServers.single.password, 'dav-pw'); - expect(again.openSubtitlesKey, 'KEY-1'); + expect(again.mkv.openSubtitlesKey, 'KEY-1'); }); test('예전 판이 서버 목록 · 비밀 값을 지운 설정 파일을 써도 되살아난다', () async { final st = store(); await st.load(); await st.save(AppSettings() - ..openSubtitlesKey = 'KEY-2' + ..mkv.openSubtitlesKey = 'KEY-2' ..remote.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw')]); // 예전 판: 모르는 항목을 지우고 저장 File(file).writeAsStringSync(jsonEncode({'homeUrl': 'https://old.example'})); @@ -66,7 +66,7 @@ void main() { expect(s.browser.homeUrl, 'https://old.example'); expect(s.remote.webdavServers.single.url, 'https://nas'); expect(s.remote.webdavServers.single.password, 'pw'); - expect(s.openSubtitlesKey, 'KEY-2'); + expect(s.mkv.openSubtitlesKey, 'KEY-2'); // 사용자가 일부러 서버를 모두 지웠으면 (목록 항목은 있음) 되살리지 않는다 final st2 = store(); final s2 = await st2.load(); @@ -81,11 +81,11 @@ void main() { const DavServer(id: 'a', name: 'a', url: 'https://a', password: 'pa'), const DavServer(id: 'b', name: 'b', url: 'https://b', password: 'pb'), ]; - s.openSubtitlesPassword = 'x'; + s.mkv.openSubtitlesPassword = 'x'; await st.save(s); expect(secrets.values.values, containsAll(['pa', 'pb', 'x'])); s.remote.webdavServers = [s.remote.webdavServers.first]; - s.openSubtitlesPassword = ''; + s.mkv.openSubtitlesPassword = ''; await st.save(s); expect(secrets.values.values, isNot(contains('pb'))); expect(secrets.values.values, isNot(contains('x'))); @@ -103,7 +103,7 @@ void main() { })); final st = store(); final s = await st.load(); - expect(s.openSubtitlesKey, 'KEY-3'); + expect(s.mkv.openSubtitlesKey, 'KEY-3'); await st.save(s..browser.homeUrl = 'https://x.example'); expect(text(file), contains('KEY-3')); expect(text(file), contains('plain')); diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index a7ee528..438e0d6 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -71,9 +71,9 @@ void main() { final secrets = MemorySecretStore()..values['sec.master'] = 'hash'; final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = await store.load() - ..openSubtitlesKey = 'k' - ..openSubtitlesUser = 'u' - ..openSubtitlesPassword = 'op' + ..mkv.openSubtitlesKey = 'k' + ..mkv.openSubtitlesUser = 'u' + ..mkv.openSubtitlesPassword = 'op' ..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..remote.sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] ..remote.smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')] diff --git a/test/subtitle_search_test.dart b/test/subtitle_search_test.dart index fd41051..4a89de1 100644 --- a/test/subtitle_search_test.dart +++ b/test/subtitle_search_test.dart @@ -181,13 +181,13 @@ void main() { addTearDown(() => dir.deleteSync(recursive: true)); final video = File(p.join(dir.path, 'Movie.2010.mkv'))..writeAsBytesSync(List.filled(200000, 7)); - final settings = AppSettings()..openSubtitlesKey = 'KEY'; + final settings = AppSettings()..mkv.openSubtitlesKey = 'KEY'; final c = AppController(PlatformServices( mediaTool: tool, storage: DesktopStorageService(), createSubtitleProviders: (s) => [ OpenSubtitlesProvider( - apiKey: () => s().openSubtitlesKey, username: () => '', password: () => '', baseUrl: api.base), + apiKey: () => s().mkv.openSubtitlesKey, username: () => '', password: () => '', baseUrl: api.base), ], )); c.settings = settings; diff --git a/test/ui_test.dart b/test/ui_test.dart index f167d78..8932942 100644 --- a/test/ui_test.dart +++ b/test/ui_test.dart @@ -195,7 +195,7 @@ void main() { // 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음). // P0: 이 시험의 가짜 저장소는 모든 파일이 "있다" 고 하므로, 결과 MKV 가 있을 때 묻지 않게 덮어쓰기로 - c.settings.mkvExisting = 'overwrite'; + c.settings.mkv.existing = 'overwrite'; await tester.tap(find.text('MKV 만들기')); await tester.pumpAndSettle(); expect(find.text('전체 만들기'), findsNothing); diff --git a/test/video_adjust_test.dart b/test/video_adjust_test.dart index 019f12f..d61f193 100644 --- a/test/video_adjust_test.dart +++ b/test/video_adjust_test.dart @@ -31,7 +31,7 @@ void main() { test('설정 저장: 화면 비율 · 맞추기 · 회전 · 색 보정', () { final s = AppSettings() - ..encode = const EncodeSettings( + ..mkv.encode = const EncodeSettings( codec: VideoCodecChoice.h264, frame: FrameChoice.square, fit: FitChoice.blur, @@ -40,10 +40,10 @@ void main() { contrast: -5, saturation: 30, temperature: 100); - final e = AppSettings.fromJson(s.toJson()).encode; + final e = AppSettings.fromJson(s.toJson()).mkv.encode; expect([e.frame, e.fit, e.rotate], [FrameChoice.square, FitChoice.blur, RotateChoice.ccw90]); expect([e.brightness, e.contrast, e.saturation, e.temperature], [10, -5, 30, 100]); - expect(AppSettings.fromJson({'encode': {'saturation': 999}}).encode.saturation, 100); + expect(AppSettings.fromJson({'encode': {'saturation': 999}}).mkv.encode.saturation, 100); }); // 실제 FFmpeg 로 만들어 보고 출력 크기 확인