설정 A: MKV · 자막 분야 (MkvSettings) - 저장 위치 · 이동 버튼 · 인코딩 · AI 자막 · OpenSubtitles 키를 분야로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 00:11:13 +09:00
parent c5aa8c657b
commit 7fe2f0562f
28 changed files with 245 additions and 217 deletions

View File

@ -72,7 +72,7 @@ void main() {
late Future<void> building; late Future<void> building;
await t.runAsync(() async { await t.runAsync(() async {
await c.init(); await c.init();
c.settings.maxParallelJobs = 1; c.settings.mkv.maxParallelJobs = 1;
await c.addVideos(files); await c.addVideos(files);
building = c.buildVideos([...c.videos]); building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {

View File

@ -46,7 +46,7 @@ void main() {
createTranslator: () => throw UnimplementedError(), createTranslator: () => throw UnimplementedError(),
)); ));
c.settings.display.showLog = true; 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++) { for (var i = 1; i <= 4; i++) {
c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 $i.mp4'))); c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 $i.mp4')));
} }

View File

@ -167,7 +167,7 @@ class AppController extends ChangeNotifier {
SmbRegistry.configure(settings.remote.smbServers); SmbRegistry.configure(settings.remote.smbServers);
FtpRegistry.configure(settings.remote.ftpServers); FtpRegistry.configure(settings.remote.ftpServers);
outputRootOverride = outputRootOverride =
(settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; (settings.mkv.outputRoot?.isNotEmpty ?? false) ? settings.mkv.outputRoot : null;
} }
void _saveSettings() => settingsStore?.save(settings); void _saveSettings() => settingsStore?.save(settings);
@ -199,14 +199,14 @@ class AppController extends ChangeNotifier {
try { try {
settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview'); settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview');
} catch (_) {} } catch (_) {}
encode = settings.encode; encode = settings.mkv.encode;
aiOptions = AiOptions( aiOptions = AiOptions(
source: languageOf(settings.aiSource), source: languageOf(settings.mkv.aiSource),
targets: { targets: {
for (final c in settings.aiTargets) for (final c in settings.mkv.aiTargets)
if (languageOf(c) != undetermined) languageOf(c), if (languageOf(c) != undetermined) languageOf(c),
}.ifEmpty({for (final c in defaultTargetLanguages) 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), orElse: () => whisperModels.first),
); );
_applySettings(); _applySettings();
@ -284,7 +284,7 @@ class AppController extends ChangeNotifier {
} }
void _encodeChanged() { void _encodeChanged() {
settings.encode = encode; settings.mkv.encode = encode;
_saveSettings(); _saveSettings();
notifyListeners(); notifyListeners();
} }
@ -888,9 +888,9 @@ class AppController extends ChangeNotifier {
if (!aiAvailable || ffmpegVersion == null || targets.isEmpty) return; if (!aiAvailable || ffmpegVersion == null || targets.isEmpty) return;
aiOptions = opts; aiOptions = opts;
settings settings
..aiSource = opts.source.code ..mkv.aiSource = opts.source.code
..aiTargets = [for (final t in opts.targets) t.code] ..mkv.aiTargets = [for (final t in opts.targets) t.code]
..aiWhisper = opts.whisper.id; ..mkv.aiWhisper = opts.whisper.id;
_saveSettings(); _saveSettings();
if (busy) { if (busy) {
for (final v in targets) { for (final v in targets) {
@ -1391,7 +1391,7 @@ class AppController extends ChangeNotifier {
/// P0: 결과 MKV 가 이미 있을 때 실제로 쓸 동작 ("묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름') /// P0: 결과 MKV 가 이미 있을 때 실제로 쓸 동작 ("묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름')
String _existingMode(String? asked) { String _existingMode(String? asked) {
final m = asked ?? settings.mkvExisting; final m = asked ?? settings.mkv.existing;
return const ['overwrite', 'skip', 'rename'].contains(m) ? m : 'rename'; return const ['overwrite', 'skip', 'rename'].contains(m) ? m : 'rename';
} }
@ -1408,7 +1408,7 @@ class AppController extends ChangeNotifier {
final queue = only?.where(videos.contains).toList() ?? final queue = only?.where(videos.contains).toList() ??
videos.where((v) => v.status != JobStatus.done).toList(); videos.where((v) => v.status != JobStatus.done).toList();
if (queue.isEmpty) return; 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)])); if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)]));
_batchQueue = queue; _batchQueue = queue;
_batchNext = 0; _batchNext = 0;

View File

@ -4,10 +4,10 @@ import 'package:path/path.dart' as p;
import '../core/reader_sources.dart' show defaultImageExtensions; import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../core/encode_options.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import 'settings/records.dart'; import 'settings/records.dart';
import 'settings/settings_group.dart'; import 'settings/settings_group.dart';
import 'settings/mkv_settings.dart';
import 'settings/display_settings.dart'; import 'settings/display_settings.dart';
import 'settings/remote_settings.dart'; import 'settings/remote_settings.dart';
import 'settings/explorer_tool_settings.dart'; import 'settings/explorer_tool_settings.dart';
@ -25,6 +25,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart'; export 'settings/records.dart';
export 'settings/settings_group.dart'; export 'settings/settings_group.dart';
export 'settings/settings_store.dart'; export 'settings/settings_store.dart';
export 'settings/mkv_settings.dart';
export 'settings/display_settings.dart'; export 'settings/display_settings.dart';
export 'settings/remote_settings.dart'; export 'settings/remote_settings.dart';
export 'settings/explorer_tool_settings.dart'; export 'settings/explorer_tool_settings.dart';
@ -41,12 +42,7 @@ export 'settings/explorer_settings.dart';
/// 환경 설정 (settings.json 에 저장) /// 환경 설정 (settings.json 에 저장)
class AppSettings { class AppSettings {
/// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv
String? mkvOutputRoot;
/// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로.
/// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다.
List<MoveTarget> moveTargets = [];
/// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝) /// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝)
List<FolderBookmark> folderBookmarks = []; List<FolderBookmark> 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 화면) ── // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름.
/// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로)
String mkvExisting = 'ask';
@ -197,8 +180,6 @@ class AppSettings {
/// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다) /// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다)
bool recycleOnDelete = true; bool recycleOnDelete = true;
/// 47: MKV 목록에서 세 번 누르면 'move' (확인 뒤 이동 폴더로) · 'none' (아무것도 안 함)
String tripleTapAction = 'move';
/// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …) /// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …)
String preferredSubtitleLanguage = ''; String preferredSubtitleLanguage = '';
@ -257,13 +238,7 @@ class AppSettings {
/// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로 /// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로
Map<String, String> externalPlayers = {}; Map<String, String> externalPlayers = {};
/// 마지막 인코딩 설정
EncodeSettings encode = const EncodeSettings();
/// 마지막 AI 자막 설정
String aiSource = 'und';
List<String> aiTargets = ['ko', 'en', 'ja'];
String aiWhisper = 'whisper-base';
AppSettings(); AppSettings();
@ -306,35 +281,21 @@ class AppSettings {
/// 화면 · 앱 모양 (배율 · 밝기 · 방향 · 언어 · 앱 아이콘 · 컴포넌트 · 화면 순서 · 밀기) /// 화면 · 앱 모양 (배율 · 밝기 · 방향 · 언어 · 앱 아이콘 · 컴포넌트 · 화면 순서 · 밀기)
final display = DisplaySettings(); final display = DisplaySettings();
/// MKV · 자막 만들기 (저장 위치 · 이동 버튼 · 인코딩 · AI 자막 · OpenSubtitles)
final mkv = MkvSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display]; List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display, mkv];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) { Map<String, Object?> toJson({bool plainSecrets = false}) {
final j = <String, Object?>{ final j = <String, Object?>{
// 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40) // 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40)
...stripSecrets(extra), ...stripSecrets(extra),
'mkvOutputRoot': mkvOutputRoot,
'moveTargets': [for (final t in moveTargets) t.toJson()],
'folderBookmarks': [for (final b in folderBookmarks) b.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, 'cookieScopeV2': true,
'maxParallelJobs': maxParallelJobs,
'mkvCompactList': mkvCompactList,
'openFileAction': openFileAction, 'openFileAction': openFileAction,
'openFileWindow': openFileWindow, 'openFileWindow': openFileWindow,
'mkvExisting': mkvExisting,
'robocopyLinksV1': true, 'robocopyLinksV1': true,
'imageExts': imageExts, 'imageExts': imageExts,
'cleanupPrechecked': cleanupPrechecked, 'cleanupPrechecked': cleanupPrechecked,
@ -342,28 +303,12 @@ class AppSettings {
'allowInnerToOuter': allowInnerToOuter, 'allowInnerToOuter': allowInnerToOuter,
'rememberPasswords': rememberPasswords, 'rememberPasswords': rememberPasswords,
'recycleOnDelete': recycleOnDelete, 'recycleOnDelete': recycleOnDelete,
'tripleTapAction': tripleTapAction,
'preferredSubtitleLanguage': preferredSubtitleLanguage, 'preferredSubtitleLanguage': preferredSubtitleLanguage,
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더 // 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
'allFilesHint2': allFilesHintShown, 'allFilesHint2': allFilesHintShown,
'subtitleScale': subtitleScale, 'subtitleScale': subtitleScale,
'playlistMode': playlistMode.name, 'playlistMode': playlistMode.name,
'externalPlayers': externalPlayers, '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) { for (final g in groups) {
g.write(j, plainSecrets: plainSecrets); g.write(j, plainSecrets: plainSecrets);
@ -371,32 +316,13 @@ class AppSettings {
return j; return j;
} }
/// 색 보정 값 (-100 ~ 100)
static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100);
factory AppSettings.fromJson(Map<String, dynamic> j) { factory AppSettings.fromJson(Map<String, dynamic> j) {
T pick<T extends Enum>(List<T> values, Object? name, T fallback) => T pick<T extends Enum>(List<T> values, Object? name, T fallback) =>
values.firstWhere((v) => v.name == name, orElse: () => fallback); values.firstWhere((v) => v.name == name, orElse: () => fallback);
final e = (j['encode'] as Map?) ?? const {};
final s = AppSettings() final s = AppSettings()
..mkvOutputRoot = j['mkvOutputRoot'] as String?
..folderBookmarks = [for (final x in (j['folderBookmarks'] as List?) ?? const []) ?FolderBookmark.fromJson(x)] ..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' ..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same' ..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
..imageExts = j['imageExts'] is List ..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()] ? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...defaultImageExtensions] : [...defaultImageExtensions]
@ -407,27 +333,11 @@ class AppSettings {
..allowInnerToOuter = j['allowInnerToOuter'] != false ..allowInnerToOuter = j['allowInnerToOuter'] != false
..rememberPasswords = j['rememberPasswords'] != false ..rememberPasswords = j['rememberPasswords'] != false
..recycleOnDelete = j['recycleOnDelete'] != false ..recycleOnDelete = j['recycleOnDelete'] != false
..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move'
..preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : '' ..preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : ''
..allFilesHintShown = j['allFilesHint2'] == true ..allFilesHintShown = j['allFilesHint2'] == true
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series) ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series)
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>() ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>();
..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<String>() ?? ['ko', 'en', 'ja']
..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base';
// 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다 // 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다
for (final g in s.groups) { for (final g in s.groups) {
g.read(j); g.read(j);

View File

@ -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<MoveTarget> 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<String> 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<String, Object?> 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<String, dynamic> 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<String>() ?? ['ko', 'en', 'ja'];
aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base';
}
}

View File

@ -54,9 +54,9 @@ class SettingsStore {
static const _davServers = 'dav.servers'; static const _davServers = 'dav.servers';
static String _os(AppSettings s, String k) => switch (k) { static String _os(AppSettings s, String k) => switch (k) {
'openSubtitlesKey' => s.openSubtitlesKey, 'openSubtitlesKey' => s.mkv.openSubtitlesKey,
'openSubtitlesUser' => s.openSubtitlesUser, 'openSubtitlesUser' => s.mkv.openSubtitlesUser,
_ => s.openSubtitlesPassword, _ => s.mkv.openSubtitlesPassword,
}; };
/// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움) /// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움)
@ -109,9 +109,9 @@ class SettingsStore {
os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? ''; os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? '';
} }
s s
..openSubtitlesKey = os['openSubtitlesKey']! ..mkv.openSubtitlesKey = os['openSubtitlesKey']!
..openSubtitlesUser = os['openSubtitlesUser']! ..mkv.openSubtitlesUser = os['openSubtitlesUser']!
..openSubtitlesPassword = os['openSubtitlesPassword']!; ..mkv.openSubtitlesPassword = os['openSubtitlesPassword']!;
s.remote.webdavServers = [ s.remote.webdavServers = [
for (final x in s.remote.webdavServers) for (final x in s.remote.webdavServers)
x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''),

View File

@ -114,9 +114,9 @@ class PlatformServices {
usage: Platform.isWindows ? WindowsUsage() : null, usage: Platform.isWindows ? WindowsUsage() : null,
createSubtitleProviders: (s) => [ createSubtitleProviders: (s) => [
OpenSubtitlesProvider( OpenSubtitlesProvider(
apiKey: () => s().openSubtitlesKey, apiKey: () => s().mkv.openSubtitlesKey,
username: () => s().openSubtitlesUser, username: () => s().mkv.openSubtitlesUser,
password: () => s().openSubtitlesPassword, password: () => s().mkv.openSubtitlesPassword,
), ),
], ],
); );
@ -137,9 +137,9 @@ class PlatformServices {
shell: AndroidShell(), shell: AndroidShell(),
createSubtitleProviders: (s) => [ createSubtitleProviders: (s) => [
OpenSubtitlesProvider( OpenSubtitlesProvider(
apiKey: () => s().openSubtitlesKey, apiKey: () => s().mkv.openSubtitlesKey,
username: () => s().openSubtitlesUser, username: () => s().mkv.openSubtitlesUser,
password: () => s().openSubtitlesPassword, password: () => s().mkv.openSubtitlesPassword,
), ),
], ],
); );

View File

@ -41,7 +41,7 @@ Future<void> showAiDialog(BuildContext context, AppController c, VideoItem curre
); );
// 환경 설정에서 "매번 묻기" 를 끄면 사용자가 고른 대로 묻지 않는다 (마지막 설정으로). // 환경 설정에서 "매번 묻기" 를 끄면 사용자가 고른 대로 묻지 않는다 (마지막 설정으로).
// 138: 다만 [MKV 만들기] 와 헷갈려 잘못 누른 것을 되돌릴 수 있게 3초 알림 ([취소]) 뒤에 시작한다. // 138: 다만 [MKV 만들기] 와 헷갈려 잘못 누른 것을 되돌릴 수 있게 3초 알림 ([취소]) 뒤에 시작한다.
if (!c.settings.askAiOptions) { if (!c.settings.mkv.askAiOptions) {
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return run(list, c.aiOptions); if (messenger == null) return run(list, c.aiOptions);
var cancelled = false; var cancelled = false;

View File

@ -10,7 +10,7 @@ import '../l10n/tr.dart';
/// 돌려주는 것: 'overwrite' · 'skip' · 'rename', 이미 있는 것이 없으면 설정 값 그대로, [취소] 면 null (만들지 않음). /// 돌려주는 것: 'overwrite' · 'skip' · 'rename', 이미 있는 것이 없으면 설정 값 그대로, [취소] 면 null (만들지 않음).
/// 예전에는 묻지 않고 시작해, 도중에 취소하면 예전에 완성한 MKV 까지 사라졌다 /// 예전에는 묻지 않고 시작해, 도중에 취소하면 예전에 완성한 MKV 까지 사라졌다
Future<String?> askExistingOutputs(BuildContext context, AppController c, List<VideoItem> targets) async { Future<String?> askExistingOutputs(BuildContext context, AppController c, List<VideoItem> targets) async {
final mode = c.settings.mkvExisting; final mode = c.settings.mkv.existing;
if (mode != 'ask') return mode; if (mode != 'ask') return mode;
final have = await c.withExistingOutput(targets); final have = await c.withExistingOutput(targets);
if (have.isEmpty || !context.mounted) return 'ask'; if (have.isEmpty || !context.mounted) return 'ask';

View File

@ -75,7 +75,7 @@ class _BookmarksDialogState extends State<_BookmarksDialog> {
// 바로 가기: 다운로드 폴더 · MKV 결과 폴더 (넣지 않아도) // 바로 가기: 다운로드 폴더 · MKV 결과 폴더 (넣지 않아도)
final shortcuts = [ final shortcuts = [
(tr('다운로드'), s.resolvedDownloadRoot()), (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(); ].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 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!)); final pairAlready = _rsync && mine.any((b) => b.isPair && samePath(b.path, widget.current) && samePath(b.right!, widget.right!));

View File

@ -861,7 +861,7 @@ class _VideoTile extends StatelessWidget {
if (!context.mounted) return; if (!context.mounted) return;
if (n == 2) { if (n == 2) {
playFiles(context, c, [v.path]); playFiles(context, c, [v.path]);
} else if (n >= 3 && c.settings.tripleTapAction == 'move') { } else if (n >= 3 && c.settings.mkv.tripleTapAction == 'move') {
// 47: 잘못 세 번 눌러도 파일이 옮겨지지 않게 묻는다 // 47: 잘못 세 번 눌러도 파일이 옮겨지지 않게 묻는다
moveToTarget(context, c, [v], confirm: true); moveToTarget(context, c, [v], confirm: true);
} }
@ -1003,7 +1003,7 @@ class _CompactBody extends StatelessWidget {
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
final h = box.maxHeight; final h = box.maxHeight;
final picked = c.selected != null; final picked = c.selected != null;
final cap = switch (c.settings.mkvCompactList) { final cap = switch (c.settings.mkv.compactList) {
'small' => h * 0.25, 'small' => h * 0.25,
'large' => h * 0.55, 'large' => h * 0.55,
// 자동: 자세히 칸 최소 - 고른 동영상이 있으면 62% (세부 정보 · 자막 버튼이 칸 밖으로 밀리지 않게, 261 · 266), // 자동: 자세히 칸 최소 - 고른 동영상이 있으면 62% (세부 정보 · 자막 버튼이 칸 밖으로 밀리지 않게, 261 · 266),
@ -1011,7 +1011,7 @@ class _CompactBody extends StatelessWidget {
_ => h - (picked ? h * 0.62 : math.min(120.0, h * 0.4)), _ => h - (picked ? h * 0.62 : math.min(120.0, h * 0.4)),
}.clamp(math.min(120.0, h), h).toDouble(); }.clamp(math.min(120.0, h), h).toDouble();
// 줄이 많아 내용이 칸보다 확실히 길면 칸 높이 그대로 (shrinkWrap 은 줄을 모두 그려 느리다) // 줄이 많아 내용이 칸보다 확실히 길면 칸 높이 그대로 (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: [ return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (shrink) if (shrink)
ConstrainedBox(constraints: BoxConstraints(maxHeight: cap), child: _VideoList(c: c, shrink: true)) 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 moving = c.checked.isEmpty ? [v] : c.batchTargets;
final canMove = moving.any((x) => x.status != JobStatus.running); final canMove = moving.any((x) => x.status != JobStatus.running);
final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; final count = c.checked.isEmpty ? '' : ' (${c.checked.length})';
final buttons = c.settings.moveTargets; final buttons = c.settings.mkv.moveTargets;
Widget button(MoveTarget? t) => Tooltip( Widget button(MoveTarget? t) => Tooltip(
message: t == null message: t == null
? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)') ? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)')
@ -1538,13 +1538,13 @@ Future<void> moveToTarget(BuildContext context, AppController c, List<VideoItem>
{MoveTarget? target, bool confirm = false}) async { {MoveTarget? target, bool confirm = false}) async {
if (videos.isEmpty) return; if (videos.isEmpty) return;
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
var t = target ?? c.settings.moveTargets.firstOrNull; var t = target ?? c.settings.mkv.moveTargets.firstOrNull;
if (t == null) { if (t == null) {
final d = await pickFolder(context, tr('이동 폴더 (환경 설정에서 바꿀 수 있음)')); final d = await pickFolder(context, tr('이동 폴더 (환경 설정에서 바꿀 수 있음)'));
if (d == null) return; if (d == null) return;
t = MoveTarget(p.basename(d).isEmpty ? tr('이동') : p.basename(d), d); t = MoveTarget(p.basename(d).isEmpty ? tr('이동') : p.basename(d), d);
final add = t; 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) { } else if (confirm && context.mounted) {
final ok = await confirmAction( final ok = await confirmAction(
context, context,

View File

@ -146,8 +146,8 @@ Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
..remote.ftpServers = [for (final x in s.remote.ftpServers) x.copyWith(password: '')] ..remote.ftpServers = [for (final x in s.remote.ftpServers) x.copyWith(password: '')]
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17)
..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')] ..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')]
..openSubtitlesKey = '' ..mkv.openSubtitlesKey = ''
..openSubtitlesPassword = '').then((_) async { ..mkv.openSubtitlesPassword = '').then((_) async {
// 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김) // 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김)
await c.settingsStore?.clearForeignSecrets(); await c.settingsStore?.clearForeignSecrets();
}); });

View File

@ -50,12 +50,12 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
title: Text(tr('동영상을 세 번 누르면')), title: Text(tr('동영상을 세 번 누르면')),
subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')), subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.tripleTapAction, value: s.mkv.tripleTapAction,
items: [ items: [
DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))), DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))),
DropdownMenuItem(value: 'none', 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 가 이미 있을 때 (처음 값: 묻기) // P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기)
@ -63,14 +63,14 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
title: Text(tr('만들 MKV 가 이미 있으면')), title: Text(tr('만들 MKV 가 이미 있으면')),
subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')), subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
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: [ items: [
DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))),
DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))), DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))),
DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))), DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))),
DropdownMenuItem(value: 'rename', child: Text(tr('다른 이름 (이름 (2).mkv)'))), 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): 폰 세로에서 목록 · 자세히 칸 나누기 // 15 지적 · 관리자 판단 (10/17): 폰 세로에서 목록 · 자세히 칸 나누기
@ -78,21 +78,21 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
title: Text(tr('폰 세로 목록 크기')), title: Text(tr('폰 세로 목록 크기')),
subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')), subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.mkvCompactList, value: s.mkv.compactList,
items: [ items: [
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))), DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))),
DropdownMenuItem(value: 'small', child: Text(tr('작게'))), DropdownMenuItem(value: 'small', child: Text(tr('작게'))),
DropdownMenuItem(value: 'large', 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( SettingTile(
title: Text(tr('동시 MKV 변환 수')), title: Text(tr('동시 MKV 변환 수')),
subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')),
trailing: CountSelector( trailing: CountSelector(
value: s.maxParallelJobs, value: s.mkv.maxParallelJobs,
onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), onChanged: (n) => c.updateSettings((x) => x.mkv.maxParallelJobs = n),
), ),
), ),
SettingTile( SettingTile(
@ -101,16 +101,16 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
), ),
settingsFolderTile( settingsFolderTile(
title: tr('MKV · 자막 (jj_mkv)'), title: tr('MKV · 자막 (jj_mkv)'),
value: s.mkvOutputRoot, value: s.mkv.outputRoot,
defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'), defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'),
onPick: () async { onPick: () async {
final d = await pickFolder(context, tr('MKV · 자막 저장 위치'), s.mkvOutputRoot); final d = await pickFolder(context, tr('MKV · 자막 저장 위치'), s.mkv.outputRoot);
if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d); 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('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')), 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(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Row(children: [ child: Row(children: [
@ -119,7 +119,7 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
final d = await pickFolder(context, tr('이동 버튼의 폴더')); final d = await pickFolder(context, tr('이동 버튼의 폴더'));
if (d == null) return; if (d == null) return;
final name = p.basename(d).isEmpty ? tr('이동') : p.basename(d); 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), icon: const Icon(Icons.add, size: 18),
label: Text(tr('이동 버튼 추가')), label: Text(tr('이동 버튼 추가')),
@ -127,7 +127,7 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
s.moveTargets.isEmpty s.mkv.moveTargets.isEmpty
? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다') ? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다')
: tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'), : tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'),
style: TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
@ -161,8 +161,8 @@ class _SubtitleSettingsSectionState extends State<SubtitleSettingsSection> {
final s = c.settings; final s = c.settings;
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
SwitchListTile( SwitchListTile(
value: s.askAiOptions, value: s.mkv.askAiOptions,
onChanged: (v) => c.updateSettings((x) => x.askAiOptions = v), onChanged: (v) => c.updateSettings((x) => x.mkv.askAiOptions = v),
title: Text(tr('시작할 때마다 설정 창 보기')), title: Text(tr('시작할 때마다 설정 창 보기')),
subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])), subtitle: 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<SubtitleSettingsSection> {
settingsSubTitle(tr('받은 AI 자막 모델')), settingsSubTitle(tr('받은 AI 자막 모델')),
AiSubtitleModels(c: c), AiSubtitleModels(c: c),
settingsSubTitle(tr('인터넷 자막 (OpenSubtitles.com)')), settingsSubTitle(tr('인터넷 자막 (OpenSubtitles.com)')),
settingsTextTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, settingsTextTile(tr('API 키 (필수, 무료)'), s.mkv.openSubtitlesKey,
tr('가입 → 프로필 → API consumers → New consumer'), tr('가입 → 프로필 → API consumers → New consumer'),
(v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())), (v) => c.updateSettings((x) => x.mkv.openSubtitlesKey = v.trim())),
settingsTextTile(tr('아이디 (선택)'), s.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'), settingsTextTile(tr('아이디 (선택)'), s.mkv.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'),
(v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), (v) => c.updateSettings((x) => x.mkv.openSubtitlesUser = v.trim())),
settingsTextTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]), settingsTextTile(tr('비밀번호 (선택)'), s.mkv.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]),
(v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), (v) => c.updateSettings((x) => x.mkv.openSubtitlesPassword = v), obscure: true),
]); ]);
} }
} }
@ -273,7 +273,7 @@ class MoveTargetTile extends StatelessWidget {
final i = index, t = target; final i = index, t = target;
final s = c.settings; final s = c.settings;
void update(List<MoveTarget> Function(List<MoveTarget>) f) => void update(List<MoveTarget> Function(List<MoveTarget>) f) =>
c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); c.updateSettings((x) => x.mkv.moveTargets = f([...x.mkv.moveTargets]));
return SettingTile( return SettingTile(
key: ValueKey('move_${i}_${t.dir}'), key: ValueKey('move_${i}_${t.dir}'),
leading: Icon(Icons.drive_file_move_outline, color: JjColors.accent), leading: Icon(Icons.drive_file_move_outline, color: JjColors.accent),
@ -302,7 +302,7 @@ class MoveTargetTile extends StatelessWidget {
IconButton( IconButton(
tooltip: tr('이 이동 버튼 지우기'), tooltip: tr('이 이동 버튼 지우기'),
icon: const Icon(Icons.delete_outline), 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,
), ),
]), ]),
); );

View File

@ -28,7 +28,7 @@ class _SearchDialogState extends State<_SearchDialog> {
final _episode = TextEditingController(); final _episode = TextEditingController();
final _key = TextEditingController(); final _key = TextEditingController();
late Set<Language> _langs = { late Set<Language> _langs = {
for (final code in widget.c.settings.aiTargets) for (final code in widget.c.settings.mkv.aiTargets)
if (languageOf(code) != undetermined) languageOf(code), if (languageOf(code) != undetermined) languageOf(code),
}; };
String? _hash; String? _hash;
@ -169,7 +169,7 @@ class _SearchDialogState extends State<_SearchDialog> {
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
if (_key.text.trim().isEmpty) return; 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(() {}); setState(() {});
_search(); _search();
}, },

View File

@ -29,7 +29,7 @@ void main() {
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); 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 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)); final v2 = VideoItem(r'C:\v\b.mp4')..info = const MediaInfo(duration: Duration(minutes: 30));
late BuildContext ctx; late BuildContext ctx;
@ -61,7 +61,7 @@ void main() {
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); 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)); final v1 = VideoItem(r'C:\v\a.mp4')..info = const MediaInfo(duration: Duration(hours: 1));
late BuildContext ctx; late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: Builder(builder: (context) { await tester.pumpWidget(MaterialApp(home: Scaffold(body: Builder(builder: (context) {

View File

@ -263,7 +263,7 @@ void main() {
} }
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init(); await c.init();
c.settings.maxParallelJobs = 1; c.settings.mkv.maxParallelJobs = 1;
c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high);
await c.addVideos(files); await c.addVideos(files);
final [v1, v2, v3] = c.videos; final [v1, v2, v3] = c.videos;
@ -304,7 +304,7 @@ void main() {
} }
c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init(); await c.init();
c.settings.maxParallelJobs = 1; c.settings.mkv.maxParallelJobs = 1;
c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high);
await c.addVideos(files); await c.addVideos(files);
return true; return true;
@ -386,7 +386,7 @@ void main() {
} }
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init(); await c.init();
c.settings.maxParallelJobs = 1; c.settings.mkv.maxParallelJobs = 1;
c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high);
await c.addVideos(files); await c.addVideos(files);
final [v1, v2, v3] = c.videos; final [v1, v2, v3] = c.videos;
@ -442,7 +442,7 @@ void main() {
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); 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') c.videos.add(VideoItem(r'D:\v\a.mp4')
..info = const MediaInfo(duration: Duration(minutes: 3), streams: [ ..info = const MediaInfo(duration: Duration(minutes: 3), streams: [
StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080), 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())); final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init(); await c.init();
c.settings c.settings
..maxParallelJobs = 1 ..mkv.maxParallelJobs = 1
..mkvExisting = 'overwrite'; ..mkv.existing = 'overwrite';
await c.addVideos(files); await c.addVideos(files);
final [v1, v2, v3] = c.videos; final [v1, v2, v3] = c.videos;
File(outputMkvPath(v1.path)) File(outputMkvPath(v1.path))
@ -800,7 +800,7 @@ void main() {
late Future<void> building; late Future<void> building;
await tester.runAsync(() async { await tester.runAsync(() async {
await c.init(); await c.init();
c.settings.maxParallelJobs = 1; c.settings.mkv.maxParallelJobs = 1;
await c.addVideos(files); await c.addVideos(files);
building = c.buildVideos([...c.videos]); building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
@ -850,7 +850,7 @@ void main() {
late Future<void> building; late Future<void> building;
await tester.runAsync(() async { await tester.runAsync(() async {
await c.init(); await c.init();
c.settings.maxParallelJobs = 5; c.settings.mkv.maxParallelJobs = 5;
await c.addVideos(files); await c.addVideos(files);
building = c.buildVideos([...c.videos]); building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.length < 2; i++) { for (var i = 0; i < 200 && tool.runs.length < 2; i++) {

View File

@ -31,7 +31,7 @@ void main() {
s.rememberPasswords, s.recycleOnDelete, s.reader.systemBars, s.rememberPasswords, s.recycleOnDelete, s.reader.systemBars,
], everyElement(isTrue)); ], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']); expect(s.cleanupPrechecked, ['work', 'download']);
expect(s.tripleTapAction, 'move'); expect(s.mkv.tripleTapAction, 'move');
expect(s.remote.davExternalOpen, 'ask'); expect(s.remote.davExternalOpen, 'ask');
// 이미 있던 설정의 기본값도 결정대로 // 이미 있던 설정의 기본값도 결정대로
expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.display.swipeNav], 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 ..rememberPasswords = false
..recycleOnDelete = false ..recycleOnDelete = false
..cleanupPrechecked = ['logs'] ..cleanupPrechecked = ['logs']
..tripleTapAction = 'none' ..mkv.tripleTapAction = 'none'
..remote.davExternalOpen = 'url') ..remote.davExternalOpen = 'url')
.toJson()); .toJson());
expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.display.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.display.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
b.recycleOnDelete], everyElement(isFalse)); 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('안쪽 → 바깥 허용을 끄면 늘 막는다', () { test('안쪽 → 바깥 허용을 끄면 늘 막는다', () {

View File

@ -139,20 +139,20 @@ https://example.com/page.html
final store = SettingsStore(p.join(dir.path, 'settings.json')); final store = SettingsStore(p.join(dir.path, 'settings.json'));
expect((await store.load()).download.clipboardWatch, isTrue); // 파일 없으면 기본값 expect((await store.load()).download.clipboardWatch, isTrue); // 파일 없으면 기본값
final s = AppSettings() final s = AppSettings()
..mkvOutputRoot = r'D:\out' ..mkv.outputRoot = r'D:\out'
..download.root = r'D:\dl' ..download.root = r'D:\dl'
..download.clipboardWatch = false ..download.clipboardWatch = false
..run.showHotkey = 'Alt+F9' ..run.showHotkey = 'Alt+F9'
..askAiOptions = false ..mkv.askAiOptions = false
..encode = const EncodeSettings(codec: VideoCodecChoice.vp9, resolution: ResolutionChoice.p720) ..mkv.encode = const EncodeSettings(codec: VideoCodecChoice.vp9, resolution: ResolutionChoice.p720)
..aiTargets = ['ko', 'fr']; ..mkv.aiTargets = ['ko', 'fr'];
await store.save(s); await store.save(s);
final l = await store.load(); 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]); [r'D:\out', r'D:\dl', false, 'Alt+F9', false]);
expect(l.encode.codec, VideoCodecChoice.vp9); expect(l.mkv.encode.codec, VideoCodecChoice.vp9);
expect(l.encode.resolution, ResolutionChoice.p720); expect(l.mkv.encode.resolution, ResolutionChoice.p720);
expect(l.aiTargets, ['ko', 'fr']); expect(l.mkv.aiTargets, ['ko', 'fr']);
expect(l.ytDlpDir, p.join(r'D:\dl', 'jj_yt-dlp')); expect(l.ytDlpDir, p.join(r'D:\dl', 'jj_yt-dlp'));
expect(l.aria2Dir, p.join(r'D:\dl', 'jj_aria2')); expect(l.aria2Dir, p.join(r'D:\dl', 'jj_aria2'));
}); });

View File

@ -191,9 +191,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..remote.webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')] ..remote.webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')]
..openSubtitlesKey = 'k' ..mkv.openSubtitlesKey = 'k'
..openSubtitlesUser = 'ou' ..mkv.openSubtitlesUser = 'ou'
..openSubtitlesPassword = 'op'; ..mkv.openSubtitlesPassword = 'op';
return c; return c;
} }
@ -256,7 +256,7 @@ void main() {
expect(find.text('초기화했습니다'), findsOneWidget); expect(find.text('초기화했습니다'), findsOneWidget);
expect(c.settings.remote.webdavServers.single.password, ''); expect(c.settings.remote.webdavServers.single.password, '');
expect(c.settings.remote.webdavServers.single.user, 'u'); 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); expect(lock.hasMaster, isFalse);
}); });
@ -283,8 +283,8 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()),
settingsStore: store); settingsStore: store);
await c.updateSettings((s) => s await c.updateSettings((s) => s
..openSubtitlesKey = 'k' ..mkv.openSubtitlesKey = 'k'
..openSubtitlesPassword = 'op' ..mkv.openSubtitlesPassword = 'op'
..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..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')] ..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')]); ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]);

View File

@ -21,7 +21,7 @@ void main() {
)); ));
c.settings c.settings
..display.showLog = true ..display.showLog = true
..mkvCompactList = size; ..mkv.compactList = size;
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
c.videos.add(VideoItem('D:/v/v$i.mp4')); c.videos.add(VideoItem('D:/v/v$i.mp4'));
} }

View File

@ -23,7 +23,7 @@ void main() {
createTranslator: () => throw UnimplementedError(), createTranslator: () => throw UnimplementedError(),
)); ));
c.settings.display.showLog = true; 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++) { for (var i = 1; i <= 4; i++) {
c.videos.add(VideoItem('D:/v/아주 긴 이름의 동영상 파일 $i.mp4')); c.videos.add(VideoItem('D:/v/아주 긴 이름의 동영상 파일 $i.mp4'));
} }

View File

@ -142,10 +142,10 @@ void main() {
test('이동 버튼 설정 저장 (표시 이름 · 폴더 · 순서), 예전 이동 폴더 하나는 "이동" 버튼으로', () { test('이동 버튼 설정 저장 (표시 이름 · 폴더 · 순서), 예전 이동 폴더 하나는 "이동" 버튼으로', () {
final s = AppSettings() final s = AppSettings()
..moveTargets = const [MoveTarget('완료', r'D:\done'), MoveTarget('보관', r'E:\archive')]; ..mkv.moveTargets = const [MoveTarget('완료', r'D:\done'), MoveTarget('보관', r'E:\archive')];
expect(AppSettings.fromJson(s.toJson()).moveTargets, s.moveTargets); expect(AppSettings.fromJson(s.toJson()).mkv.moveTargets, s.mkv.moveTargets);
expect(AppSettings().moveTargets, isEmpty); expect(AppSettings().mkv.moveTargets, isEmpty);
expect(AppSettings.fromJson({'moveTargetDir': r'D:\old'}).moveTargets, const [MoveTarget('이동', r'D:\old')]); expect(AppSettings.fromJson({'moveTargetDir': r'D:\old'}).mkv.moveTargets, const [MoveTarget('이동', r'D:\old')]);
}); });
testWidgets('MKV 목록: 세 번 누르면 첫 번째 이동 버튼으로 · 세부 정보의 이동 버튼 (표시 이름) 은 그 폴더로', (tester) async { testWidgets('MKV 목록: 세 번 누르면 첫 번째 이동 버튼으로 · 세부 정보의 이동 버튼 (표시 이름) 은 그 폴더로', (tester) async {
@ -155,7 +155,7 @@ void main() {
final c = _controller(); final c = _controller();
final dest = p.join(dir.path, 'done'); final dest = p.join(dir.path, 'done');
final archive = p.join(dir.path, 'archive'); 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 a = VideoItem(make('a.mp4').path);
final b = VideoItem(make('b.mp4').path); final b = VideoItem(make('b.mp4').path);
final d = VideoItem(make('d.mp4').path); final d = VideoItem(make('d.mp4').path);
@ -200,7 +200,7 @@ void main() {
} }
expect(File(p.join(dest, 'b.mp4')).existsSync(), isTrue); expect(File(p.join(dest, 'b.mp4')).existsSync(), isTrue);
// 102: 세 번 누르기를 "아무것도 안 함" 으로 하면 묻지도 옮기지도 않는다 // 102: 세 번 누르기를 "아무것도 안 함" 으로 하면 묻지도 옮기지도 않는다
c.settings.tripleTapAction = 'none'; c.settings.mkv.tripleTapAction = 'none';
final dTile = find.text('d.mp4').first; final dTile = find.text('d.mp4').first;
for (var i = 0; i < 3; i++) { for (var i = 0; i < 3; i++) {
await tester.tap(dTile); await tester.tap(dTile);
@ -210,7 +210,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('파일 옮기기'), findsNothing); expect(find.text('파일 옮기기'), findsNothing);
expect(File(p.join(dest, 'd.mp4')).existsSync(), isFalse); 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']); expect(c.videos.map((v) => v.fileName), ['a.mp4', 'd.mp4']);
// 세부 정보 오른쪽 아래: 표시 이름 버튼 두 개 (첫 번째가 아래, 두 번째가 그 위) // 세부 정보 오른쪽 아래: 표시 이름 버튼 두 개 (첫 번째가 아래, 두 번째가 그 위)

View File

@ -164,7 +164,7 @@ void main() {
final tool = _Tool(); final tool = _Tool();
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
await c.init(); await c.init();
c.settings.maxParallelJobs = limit; c.settings.mkv.maxParallelJobs = limit;
final dir = Directory.systemTemp.createTempSync('jj_par_'); final dir = Directory.systemTemp.createTempSync('jj_par_');
for (var i = 0; i < 5; i++) { for (var i = 0; i < 5; i++) {
final f = File(p.join(dir.path, 'v$i.mp4'))..writeAsStringSync(''); // 없는 파일은 만들지 않으므로 final f = File(p.join(dir.path, 'v$i.mp4'))..writeAsStringSync(''); // 없는 파일은 만들지 않으므로

View File

@ -35,8 +35,8 @@ void main() {
'futureFeature': {'x': 1}, 'futureFeature': {'x': 1},
})); }));
final s = await store().load(); final s = await store().load();
expect(s.openSubtitlesKey, 'KEY-1'); expect(s.mkv.openSubtitlesKey, 'KEY-1');
expect(s.openSubtitlesPassword, 'os-pw'); expect(s.mkv.openSubtitlesPassword, 'os-pw');
expect(s.remote.webdavServers.single.password, 'dav-pw'); expect(s.remote.webdavServers.single.password, 'dav-pw');
expect(secrets.values.values, containsAll(['KEY-1', 'me', 'os-pw', 'dav-pw'])); expect(secrets.values.values, containsAll(['KEY-1', 'me', 'os-pw', 'dav-pw']));
for (final f in [file, '$file.bak']) { for (final f in [file, '$file.bak']) {
@ -51,14 +51,14 @@ void main() {
// 다시 켜도 그대로 // 다시 켜도 그대로
final again = await store().load(); final again = await store().load();
expect(again.remote.webdavServers.single.password, 'dav-pw'); expect(again.remote.webdavServers.single.password, 'dav-pw');
expect(again.openSubtitlesKey, 'KEY-1'); expect(again.mkv.openSubtitlesKey, 'KEY-1');
}); });
test('예전 판이 서버 목록 · 비밀 값을 지운 설정 파일을 써도 되살아난다', () async { test('예전 판이 서버 목록 · 비밀 값을 지운 설정 파일을 써도 되살아난다', () async {
final st = store(); final st = store();
await st.load(); await st.load();
await st.save(AppSettings() 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')]); ..remote.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw')]);
// 예전 판: 모르는 항목을 지우고 저장 // 예전 판: 모르는 항목을 지우고 저장
File(file).writeAsStringSync(jsonEncode({'homeUrl': 'https://old.example'})); File(file).writeAsStringSync(jsonEncode({'homeUrl': 'https://old.example'}));
@ -66,7 +66,7 @@ void main() {
expect(s.browser.homeUrl, 'https://old.example'); expect(s.browser.homeUrl, 'https://old.example');
expect(s.remote.webdavServers.single.url, 'https://nas'); expect(s.remote.webdavServers.single.url, 'https://nas');
expect(s.remote.webdavServers.single.password, 'pw'); expect(s.remote.webdavServers.single.password, 'pw');
expect(s.openSubtitlesKey, 'KEY-2'); expect(s.mkv.openSubtitlesKey, 'KEY-2');
// 사용자가 일부러 서버를 모두 지웠으면 (목록 항목은 있음) 되살리지 않는다 // 사용자가 일부러 서버를 모두 지웠으면 (목록 항목은 있음) 되살리지 않는다
final st2 = store(); final st2 = store();
final s2 = await st2.load(); 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: 'a', name: 'a', url: 'https://a', password: 'pa'),
const DavServer(id: 'b', name: 'b', url: 'https://b', password: 'pb'), const DavServer(id: 'b', name: 'b', url: 'https://b', password: 'pb'),
]; ];
s.openSubtitlesPassword = 'x'; s.mkv.openSubtitlesPassword = 'x';
await st.save(s); await st.save(s);
expect(secrets.values.values, containsAll(['pa', 'pb', 'x'])); expect(secrets.values.values, containsAll(['pa', 'pb', 'x']));
s.remote.webdavServers = [s.remote.webdavServers.first]; s.remote.webdavServers = [s.remote.webdavServers.first];
s.openSubtitlesPassword = ''; s.mkv.openSubtitlesPassword = '';
await st.save(s); await st.save(s);
expect(secrets.values.values, isNot(contains('pb'))); expect(secrets.values.values, isNot(contains('pb')));
expect(secrets.values.values, isNot(contains('x'))); expect(secrets.values.values, isNot(contains('x')));
@ -103,7 +103,7 @@ void main() {
})); }));
final st = store(); final st = store();
final s = await st.load(); 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'); await st.save(s..browser.homeUrl = 'https://x.example');
expect(text(file), contains('KEY-3')); expect(text(file), contains('KEY-3'));
expect(text(file), contains('plain')); expect(text(file), contains('plain'));

View File

@ -71,9 +71,9 @@ void main() {
final secrets = MemorySecretStore()..values['sec.master'] = 'hash'; final secrets = MemorySecretStore()..values['sec.master'] = 'hash';
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = await store.load() final s = await store.load()
..openSubtitlesKey = 'k' ..mkv.openSubtitlesKey = 'k'
..openSubtitlesUser = 'u' ..mkv.openSubtitlesUser = 'u'
..openSubtitlesPassword = 'op' ..mkv.openSubtitlesPassword = 'op'
..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..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.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')] ..remote.smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')]

View File

@ -181,13 +181,13 @@ void main() {
addTearDown(() => dir.deleteSync(recursive: true)); addTearDown(() => dir.deleteSync(recursive: true));
final video = File(p.join(dir.path, 'Movie.2010.mkv'))..writeAsBytesSync(List.filled(200000, 7)); 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( final c = AppController(PlatformServices(
mediaTool: tool, mediaTool: tool,
storage: DesktopStorageService(), storage: DesktopStorageService(),
createSubtitleProviders: (s) => [ createSubtitleProviders: (s) => [
OpenSubtitlesProvider( OpenSubtitlesProvider(
apiKey: () => s().openSubtitlesKey, username: () => '', password: () => '', baseUrl: api.base), apiKey: () => s().mkv.openSubtitlesKey, username: () => '', password: () => '', baseUrl: api.base),
], ],
)); ));
c.settings = settings; c.settings = settings;

View File

@ -195,7 +195,7 @@ void main() {
// 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음). // 체크한 것이 없으면 세부 정보에 보이는 동영상을 바로 만든다 (묻지 않음).
// P0: 이 시험의 가짜 저장소는 모든 파일이 "있다" 고 하므로, 결과 MKV 가 있을 때 묻지 않게 덮어쓰기로 // P0: 이 시험의 가짜 저장소는 모든 파일이 "있다" 고 하므로, 결과 MKV 가 있을 때 묻지 않게 덮어쓰기로
c.settings.mkvExisting = 'overwrite'; c.settings.mkv.existing = 'overwrite';
await tester.tap(find.text('MKV 만들기')); await tester.tap(find.text('MKV 만들기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('전체 만들기'), findsNothing); expect(find.text('전체 만들기'), findsNothing);

View File

@ -31,7 +31,7 @@ void main() {
test('설정 저장: 화면 비율 · 맞추기 · 회전 · 색 보정', () { test('설정 저장: 화면 비율 · 맞추기 · 회전 · 색 보정', () {
final s = AppSettings() final s = AppSettings()
..encode = const EncodeSettings( ..mkv.encode = const EncodeSettings(
codec: VideoCodecChoice.h264, codec: VideoCodecChoice.h264,
frame: FrameChoice.square, frame: FrameChoice.square,
fit: FitChoice.blur, fit: FitChoice.blur,
@ -40,10 +40,10 @@ void main() {
contrast: -5, contrast: -5,
saturation: 30, saturation: 30,
temperature: 100); 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.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([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 로 만들어 보고 출력 크기 확인 // 실제 FFmpeg 로 만들어 보고 출력 크기 확인