설정 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;
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++) {

View File

@ -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')));
}

View File

@ -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;

View File

@ -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<MoveTarget> moveTargets = [];
/// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝)
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 화면) ──
/// 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<String, String> externalPlayers = {};
/// 마지막 인코딩 설정
EncodeSettings encode = const EncodeSettings();
/// 마지막 AI 자막 설정
String aiSource = 'und';
List<String> 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<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]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
final j = <String, Object?>{
// 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (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<String, dynamic> j) {
T pick<T extends Enum>(List<T> 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<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';
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>();
// 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다
for (final g in s.groups) {
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 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)] ?? ''),

View File

@ -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,
),
],
);

View File

@ -41,7 +41,7 @@ Future<void> 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;

View File

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

View File

@ -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!));

View File

@ -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<void> moveToTarget(BuildContext context, AppController c, List<VideoItem>
{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,

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: '')]
// 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();
});

View File

@ -50,12 +50,12 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
title: Text(tr('동영상을 세 번 누르면')),
subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')),
trailing: DropdownButton<String>(
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<MkvSettingsSection> {
title: Text(tr('만들 MKV 가 이미 있으면')),
subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')),
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: [
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<MkvSettingsSection> {
title: Text(tr('폰 세로 목록 크기')),
subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')),
trailing: DropdownButton<String>(
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<MkvSettingsSection> {
),
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<MkvSettingsSection> {
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<MkvSettingsSection> {
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<SubtitleSettingsSection> {
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<SubtitleSettingsSection> {
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<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(
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,
),
]),
);

View File

@ -28,7 +28,7 @@ class _SearchDialogState extends State<_SearchDialog> {
final _episode = TextEditingController();
final _key = TextEditingController();
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),
};
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();
},

View File

@ -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) {

View File

@ -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<void> 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<void> 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++) {

View File

@ -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('안쪽 → 바깥 허용을 끄면 늘 막는다', () {

View File

@ -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'));
});

View File

@ -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')]);

View File

@ -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'));
}

View File

@ -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'));
}

View File

@ -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']);
// 세부 정보 오른쪽 아래: 표시 이름 버튼 두 개 (첫 번째가 아래, 두 번째가 그 위)

View File

@ -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(''); // 없는 파일은 만들지 않으므로

View File

@ -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'));

View File

@ -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')]

View File

@ -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;

View File

@ -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);

View File

@ -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 로 만들어 보고 출력 크기 확인