1241 lines
67 KiB
Dart
1241 lines
67 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import 'components.dart';
|
||
import '../core/reader_sources.dart' show defaultImageExtensions;
|
||
import '../core/dns_resolver.dart';
|
||
import '../core/download_detect.dart';
|
||
import '../core/encode_options.dart';
|
||
import '../core/playlist.dart';
|
||
import '../core/ftp.dart';
|
||
import '../core/sftp.dart';
|
||
import '../core/smb.dart';
|
||
import '../core/webdav.dart';
|
||
import '../services/image_ai.dart' show AiService;
|
||
import '../services/app_shell.dart' show appIconOf;
|
||
import '../core/remote_idle.dart';
|
||
import 'settings/records.dart';
|
||
import 'settings/settings_group.dart';
|
||
|
||
export 'settings/records.dart';
|
||
export 'settings/settings_group.dart';
|
||
export 'settings/settings_store.dart';
|
||
|
||
/// 환경 설정 (settings.json 에 저장)
|
||
class AppSettings {
|
||
/// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv
|
||
String? mkvOutputRoot;
|
||
|
||
/// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로.
|
||
/// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다.
|
||
List<MoveTarget> moveTargets = [];
|
||
|
||
/// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝)
|
||
List<FolderBookmark> folderBookmarks = [];
|
||
|
||
/// 웹 브라우저에서 YouTube 광고 자동 건너뛰기 (건너뛰기 버튼 누르기 · 건너뛸 수 없는 광고는 빨리 감기 · 소리 끄기)
|
||
bool youtubeAdSkip = true;
|
||
|
||
/// YouTube 페이지의 광고 배너 · 광고 영역 숨기기
|
||
bool youtubeAdHide = true;
|
||
|
||
/// 웹 브라우저 위에 다른 화면이 올라오거나 다른 화면으로 가면 페이지의 동영상 · 소리를 멈춤 (끄면 계속 들림)
|
||
bool webPauseOnLeave = true;
|
||
|
||
/// 다운로드 위치 (아래에 jj_yt-dlp, jj_aria2 생성). null 이면 프로그램 폴더
|
||
String? downloadRoot;
|
||
|
||
/// Ctrl+C 로 복사한 주소를 자동으로 다운로드
|
||
bool clipboardWatch = true;
|
||
|
||
/// 최소화하면 작업 표시줄 대신 트레이로
|
||
bool minimizeToTray = true;
|
||
|
||
/// 종료 (창 X · 종료 버튼) 를 눌렀을 때:
|
||
/// 'background' 창만 숨기고 다운로드 · 변환 계속 / 'quit' 묻지 않고 모두 종료 / 'ask' 매번 고르기
|
||
String closeAction = 'background';
|
||
|
||
/// 창 보이기 단축키 (예: Ctrl+Shift+X)
|
||
String showHotkey = 'Ctrl+Shift+X';
|
||
|
||
/// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작)
|
||
bool askAiOptions = true;
|
||
|
||
/// 다운로드가 끝난 동영상을 편집 목록(MKV 만들기)에 자동으로 추가
|
||
bool addFinishedDownloads = true;
|
||
|
||
/// 마그넷 · 토렌트가 이 초 동안 메타데이터 · 피어를 못 받으면 그 줄에 이유 후보 · [다시 시도] (0 = 알리지 않음, 관리자 지시 10/20)
|
||
int torrentStallSeconds = 60;
|
||
|
||
/// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남)
|
||
String openSubtitlesKey = '';
|
||
String openSubtitlesUser = '';
|
||
String openSubtitlesPassword = '';
|
||
|
||
/// YouTube 받을 형식 · 화질 (기본: MP4 최고 화질)
|
||
YtContainer ytContainer = YtContainer.mp4;
|
||
YtQuality ytQuality = YtQuality.best;
|
||
|
||
/// 36: 같은 크기면 H.264 를 먼저 (휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 못 푸는 일이 많음). 기본: Android 켜짐, PC 꺼짐
|
||
bool ytPreferH264 = Platform.isAndroid;
|
||
|
||
/// 재생목록 주소면 목록 전체를 받기
|
||
bool ytExpandPlaylists = true;
|
||
|
||
/// YouTube 로봇 확인 대응: 쿠키를 가져올 브라우저 (빈 값 = 사용 안 함) 또는 cookies.txt 경로
|
||
String ytCookiesBrowser = internalBrowserCookies; // 기본: 앱 안 브라우저 로그인 사용
|
||
|
||
/// 53: 앱 안 브라우저 로그인 쿠키를 넘길 사이트 (처음은 모두 - 28)
|
||
List<String> loginCookieSites = [...loginCookieDomains];
|
||
String ytCookiesFile = '';
|
||
|
||
/// 동시에 받는 다운로드 수 (0 = 무제한)
|
||
int maxParallelDownloads = 3;
|
||
|
||
/// 동시에 만드는 MKV 수 (0 = 무제한)
|
||
int maxParallelJobs = 5;
|
||
|
||
/// 메인 화면 아래 작업 기록 창 보이기
|
||
bool showLog = true;
|
||
|
||
/// 폰 세로 (좁은 화면) MKV 목록 크기: 'auto' 목록은 내용만큼 · 자세히 칸에 최소 높이 (처음 값) · 'small' · 'large' (관리자 판단 10/17)
|
||
String mkvCompactList = 'auto';
|
||
|
||
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([homeUrl]), 'files' = 파일 탐색기
|
||
String startScreen = 'home';
|
||
|
||
/// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈),
|
||
/// [uiScaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감)
|
||
double uiScale = 1.0;
|
||
double uiScaleDefault = 1.0;
|
||
// 18: 50 ~ 250% (사용자 결정)
|
||
static const double uiScaleMin = 0.5, uiScaleMax = 2.5;
|
||
|
||
/// X-plore 비교 5-1 ⑦ 화면 밝기 (관리자 지시 10/21): 'dark' 어둡게 (처음 값 - 예전 그대로) · 'light' 밝게 · 'system' 기기 따라.
|
||
/// 다 끝날 때까지 설정에 "시험" 표시. 모르는 값은 어둡게
|
||
String uiBrightness = 'dark';
|
||
static const uiBrightnessModes = ['dark', 'light', 'system'];
|
||
static double clampUiScale(num? v) =>
|
||
((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20;
|
||
|
||
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
|
||
String openFileAction = 'play';
|
||
|
||
/// Android 화면 방향: 'auto' 기기 방향 따라 (기본) / 'landscape' 가로 고정 / 'portrait' 세로 고정.
|
||
/// 값이 없거나 틀리면 자동 (제한 없는 쪽). 고정은 사용자가 환경 설정에서 고를 때만.
|
||
String screenOrientation = 'auto';
|
||
|
||
/// 화면 언어 (ko · en · ja · zh-Hans, 또는 더한 언어)
|
||
String uiLanguage = 'system';
|
||
|
||
/// 앱 아이콘 (appIconIds 중 하나: 'yellow' 기본 · 'black' · 'film_jj' · 'film' · 'blue')
|
||
String appIcon = 'yellow';
|
||
|
||
/// 더한 화면 언어 (AI 로 자동 번역한 사전이 설정 폴더 l10n 에 있음)
|
||
List<String> uiLanguagesAdded = [];
|
||
|
||
/// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창
|
||
String openFileWindow = 'same';
|
||
String homeUrl = 'https://www.youtube.com/';
|
||
|
||
/// 앱 안 브라우저 엔진: 'edge' (WebView2, 기본). 'chrome' 은 내려받아 연결 (준비 중)
|
||
String browserEngine = 'edge';
|
||
|
||
/// 207: 관리자 권한으로 실행 중일 때도 내장 Chrome 엔진을 쓸지 (처음 꺼짐 - 관리자 판단: 보안이 먼저, CEF 는 샌드박스가 꺼져 있다)
|
||
bool cefWhenElevated = false;
|
||
|
||
/// "외부 브라우저로 열기" 에 쓸 브라우저: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale'
|
||
String externalBrowser = 'system';
|
||
|
||
/// 앱 안 브라우저: 다른 언어로 된 웹 페이지를 화면 언어 ([uiLanguage]) 로 자동 번역 (Google 번역)
|
||
bool webTranslate = false;
|
||
|
||
/// 앱 안 브라우저: 페이지의 JavaScript 실행 (끄면 스크립트 없이 글 · 그림만)
|
||
bool webJavaScript = true;
|
||
|
||
// ── 네트워크 · DNS (사용자 요청 10/10: "DNS 주소를 강제로 내가 설정한 주소로", 기본 8.8.8.8 · 보조 8.8.4.4 - 관리자 판단) ──
|
||
/// 'system' 기기 DNS (끄기) · 'custom' 직접 지정 (기본) · 'doh' DNS over HTTPS
|
||
String dnsMode = DnsConfig.defaultMode;
|
||
String dnsPrimary = DnsConfig.defaultPrimary;
|
||
String dnsSecondary = DnsConfig.defaultSecondary;
|
||
String dnsDohUrl = DnsConfig.defaultDohUrl;
|
||
|
||
DnsConfig get dnsConfig => DnsConfig(mode: dnsMode, primary: dnsPrimary, secondary: dnsSecondary, dohUrl: dnsDohUrl);
|
||
|
||
// ── Tor 브라우저 (사용자 요청 10/10) ──
|
||
/// Tor 화면의 쿠키 · 기록 · 캐시를 닫은 뒤에도 남기기 (기본: 닫으면 지움)
|
||
bool torKeepData = false;
|
||
|
||
/// Tor 화면을 떠난 뒤 tor 를 몇 분 더 켜 둘지 (0 = 바로 끔, 처음 2분 - 관리자 결정 10/11). 웹 뷰 · 다리 · 프록시는 늘 바로 정리
|
||
int torIdleMinutes = 2;
|
||
|
||
/// Tor 화면의 처음 · 🏠 주소
|
||
String torHomeUrl = defaultTorHome;
|
||
static const defaultTorHome = 'https://check.torproject.org/';
|
||
|
||
/// Tor 화면의 JavaScript (끄면 더 안전하지만 많은 사이트가 보이지 않음)
|
||
bool torJavaScript = true;
|
||
|
||
/// "공식 Tor Browser 가 아닙니다" 안내를 보았는지 (처음 열 때 한 번)
|
||
bool torNoticeSeen = false;
|
||
|
||
// ── 파일 탐색기 (X-plore 참고) ──
|
||
/// 창: 'dual' 두 창 (기본 - 폰 세로도, 규칙 7 · 결정 3 10/22) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 ·
|
||
/// 'single' 한 창 · 'auto' 화면 크기 따라 (폰 세로는 한 창)
|
||
String explorerLayout = 'dual';
|
||
|
||
/// X-plore 비교 a: 한 창일 때 복사 · 이동 - 'clip' 담아 두었다가 붙여넣기 (지금 그대로, 처음 값) /
|
||
/// 'other' 숨은 다른 창의 폴더로 바로 (X-plore 처럼, 위에 "갈 곳" 줄). 예전 동작과 달라 처음 값은 그대로 (관리자 결정)
|
||
String explorerOnePaneCopy = 'clip';
|
||
|
||
/// 펼친 폴더를 다시 누르면 접기 (규칙 3 "폴더 한 번 누르기 = 펼치기 · 접기", 처음 값 켬 - E3 10/22).
|
||
/// 두 번 누르기의 두 번째는 접지 않는다 ([ClickPolicy] repeat)
|
||
bool explorerRetapCollapse = true;
|
||
|
||
/// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (%, 80~150, 처음 100 = 지금 그대로).
|
||
/// 앱 화면 크기 · 기기 글자 크기와 곱해진다
|
||
int explorerTextScale = 100;
|
||
static const explorerTextScaleMin = 80, explorerTextScaleMax = 150;
|
||
|
||
/// X-plore 비교 40: 목록 줄 간격 'compact' 좁게 · 'normal' 보통 (처음 값 = 지금 그대로) · 'wide' 넓게
|
||
String explorerRowSpacing = 'normal';
|
||
|
||
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
|
||
Map<String, dynamic> batchRenameLast = {};
|
||
|
||
/// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께).
|
||
/// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로)
|
||
bool explorerRootsInTree = true;
|
||
|
||
/// X-plore 스타일에서 줄 끝 표시를 늘 보이기. 처음 값 끔 - 규칙 4 "선택 표시는 선택 모드에서만" (E3 10/22)
|
||
bool explorerAlwaysMarks = false;
|
||
|
||
|
||
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (설정으로만, 처음 값 끔 - 10/8 사용자 결정 "화면 가운데를 밀면 화면 이동" 을 지킴, 관리자 판단 10/18)
|
||
bool explorerSwipePanes = false;
|
||
|
||
/// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통
|
||
int androidTrashDays = 14;
|
||
|
||
/// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본, 두 기기 - 규칙 2) / 'open' 한 번 = 바로 열기.
|
||
/// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴.
|
||
String explorerClick = 'select';
|
||
|
||
/// 모양: 'xplore' (기본) · 'windows' Windows 탐색기 · 'totalcmd' Total Commander.
|
||
/// 규칙 1 의 Windows 처음 값 (Total Commander) 과 저장된 값 묻기 (결정 1) 는 E6 (TC 모양) 과 함께 (관리자 결정 10/22)
|
||
String explorerStyle = 'xplore';
|
||
|
||
/// 두 창 배치: 'side' 좌우 (기본, 118 - 폰 세로에서도) · 'stacked' 위아래 · 'auto' 화면 모양 따라 (가로로 넓으면 좌우, 세로로 길면 위아래)
|
||
String explorerOrientation = 'side';
|
||
|
||
/// 기능 버튼 줄: 'middle' 두 창 사이 (기본) · 'edge' 오른쪽 (위아래 배치면 아래) 끝 · 'hidden' 숨김
|
||
String explorerToolbar = 'middle';
|
||
|
||
/// 기능 버튼 줄에 보일 버튼과 순서 (ExplorerButton 이름). 비어 있으면 기본 구성.
|
||
List<String> explorerButtons = [];
|
||
|
||
/// 정렬 · 숨긴 항목
|
||
String explorerSort = 'name';
|
||
bool explorerSortDesc = false;
|
||
|
||
/// 84: 정렬을 폴더마다 기억 (처음: 끔 - 모든 폴더가 같은 정렬). 켜면 열 머리 · 정렬 창이 지금 폴더에만 쓰고 [explorerFolderSorts] 에 둔다
|
||
bool explorerSortPerFolder = false;
|
||
|
||
/// X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 - 사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름.
|
||
/// 처음 끔 (지금 정렬 그대로). 켜도 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억한다 ([explorerFolderSorts])
|
||
bool explorerSortByKind = false;
|
||
|
||
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 (WebDAV · SFTP · SMB · FTP) 썸네일 - 'wifi' 데이터 요금제가 아닌 망에서만 (처음 값) ·
|
||
/// 'always' 늘 · 'off' 받지 않음. X-plore 모양의 목록에서 보이는 줄의 그림 · 동영상만, 동시에 2개까지
|
||
String explorerRemoteThumbs = 'wifi';
|
||
|
||
/// 원격 썸네일 캐시 상한 (MB, 처음 200) - 넘으면 오래 쓰지 않은 것부터 지운다
|
||
int explorerRemoteThumbCacheMb = 200;
|
||
static const remoteThumbCacheChoices = [50, 100, 200, 500, 1000];
|
||
|
||
/// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 중 사용자가 바꾼 것만 (동작 → 키, '' = 그 동작은 단축키 없음).
|
||
/// 비어 있으면 (처음) 스타일대로 (83) - ui/explorer_keys.dart
|
||
Map<String, String> explorerKeyOverrides = {};
|
||
|
||
/// X-plore 비교 5-2 암호 보관함 (사용자 결정 10/21, 관리자 결정): 처음 끔. 보관함 자체는 설정이 아니라 그 폴더 (vault.json · *.jjv) 에 있다 -
|
||
/// 설정을 처음 상태로 돌려도 보관함은 그대로 (끄기만 됨)
|
||
bool vaultEnabled = false;
|
||
|
||
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용, 사용자 결정 10/21 · 관리자 결정: 처음 끔 · 켤 때마다 경고)
|
||
bool shizukuEnabled = false;
|
||
|
||
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용, 관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 켤 때 · 처음 열 때 경고)
|
||
bool rootEnabled = false;
|
||
|
||
/// [마스터로도 열기] - 보관함 열쇠 한 벌을 안전 저장소 (마스터 비밀번호로 막힘) 에 둔다. 처음 끔. 마스터를 초기화하면 그 한 벌은 지워진다
|
||
bool vaultMasterCopy = false;
|
||
|
||
/// 앱이 뒤로 간 뒤 이만큼 (초) 지나면 다시 잠금 (처음 60)
|
||
int vaultAutoLockSeconds = 60;
|
||
|
||
/// 84: 폴더 → "기준|desc" (예: "size|1"). 오래된 것부터 지워 300개까지
|
||
Map<String, String> explorerFolderSorts = {};
|
||
|
||
/// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨)
|
||
String explorerFolderSize = 'sort';
|
||
|
||
/// 82: 창 사이 끌어 놓기 - 'ask' 묻기 (처음 값, 관리자 판단) · 'copy' 복사 · 'move' 이동
|
||
String explorerDropAction = 'ask';
|
||
|
||
/// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임
|
||
bool explorerProgressHidden = false;
|
||
|
||
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
|
||
int wifiShareMinutes = 30;
|
||
|
||
/// 89 · 보안 검토 3: Wi-Fi 공유를 Tailscale (100.64/10, 내 tailnet 의 기기) 주소에서도 연다 (관리자 결정 10/18: 처음 켬)
|
||
bool wifiShareTailscale = true;
|
||
|
||
/// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것)
|
||
String wifiShareFolder = '';
|
||
|
||
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
|
||
int zipLevel = 6;
|
||
bool explorerShowHidden = false;
|
||
|
||
/// X-plore 숨기기 (관리자 지시 10/18): 파일 탐색기 목록에서 숨긴 파일 · 폴더의 경로 (파일 자체는 바꾸지 않는다).
|
||
/// 지우기 · 이동 대상에서는 빠지지 않는다. 목록 맨 아래 [숨긴 항목 보이기] 로 다시 본다
|
||
List<String> explorerHiddenItems = [];
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 확인 창 (처음 켬, 창의 [다음부터 묻지 않기] · 환경 설정에서 끔)
|
||
bool explorerHideConfirm = true;
|
||
|
||
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
|
||
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
|
||
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
|
||
String copyMethodFile = 'builtin';
|
||
|
||
/// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip'
|
||
String copyConflict = 'ask';
|
||
|
||
/// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름.
|
||
/// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로)
|
||
String mkvExisting = 'ask';
|
||
|
||
/// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음).
|
||
/// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단)
|
||
bool copySkipNewer = true;
|
||
String copyMethodFolder = 'builtin';
|
||
String rsyncOptions = '-avPog';
|
||
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
||
|
||
/// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync)
|
||
String copyRunMode = 'each';
|
||
|
||
/// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게)
|
||
int copyBandwidthKBps = 0;
|
||
|
||
/// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath])
|
||
String rsyncSource = 'download';
|
||
String rsyncPath = '';
|
||
|
||
/// 실시간 동기화 (lsyncd 처럼)
|
||
List<LiveSyncPair> liveSyncPairs = [];
|
||
|
||
/// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기
|
||
List<CopyTask> copyTasks = [];
|
||
|
||
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
|
||
List<String> rsyncPaths = [];
|
||
|
||
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
|
||
List<String> components = AppComponent.defaultInstalled;
|
||
|
||
/// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로
|
||
List<String> navOrder = AppComponent.defaultOrder;
|
||
|
||
/// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로
|
||
bool swipeNav = true;
|
||
|
||
/// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다)
|
||
List<String> imageExts = [...defaultImageExtensions];
|
||
|
||
/// 보기: 'page' 한 쪽 맞추기 · 'width' 좌우 맞추기 (세로로 밀어 봄)
|
||
String readerFit = 'page';
|
||
|
||
/// 보기: 오른쪽에서 왼쪽으로 넘김 (일본 만화)
|
||
bool readerRtl = false;
|
||
|
||
/// 보기: 밝기 (-0.7 어둡게 ~ 0.7 밝게)
|
||
double readerBrightness = 0;
|
||
|
||
/// 30 · 101: 보기 화면에서 위 · 아래 시스템 막대 (시계 · 알림 · 뒤로) 를 그대로 보이기 (기본: 보임 - 기기의 평소 동작, 화면 가득은 버튼 · 설정으로)
|
||
bool readerSystemBars = true;
|
||
|
||
/// 117: 보기 화면 "계속 보기" 간격 (초, 1 ~ 60)
|
||
int readerAutoSeconds = 3;
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔
|
||
bool readerGallery = false;
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면에서 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android). 처음 끔.
|
||
/// 보기 화면에서만 가로챈다 (동영상 · 다른 화면의 볼륨은 그대로)
|
||
bool readerVolumeKeys = false;
|
||
|
||
/// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함
|
||
String readerSplit = 'auto';
|
||
|
||
/// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다
|
||
bool startMenuShortcut = true;
|
||
|
||
/// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림
|
||
bool syncStopToast = true;
|
||
|
||
// ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ──
|
||
|
||
/// 66: 좌우로 밀 때 끝에서 처음으로 (끄면 끝에서 멈춤)
|
||
bool swipeWrap = true;
|
||
|
||
/// 66-2: 웹 브라우저에서 화면 옮기기 (위쪽 막대를 좌우로) 안내를 한 번 보였는지
|
||
bool browserSwipeHinted = false;
|
||
|
||
/// 저장 공간 정리 창에서 미리 체크할 묶음 (작업 임시 파일 · 받다 만 다운로드)
|
||
List<String> cleanupPrechecked = ['work', 'download'];
|
||
|
||
/// 19: 업데이트로 바뀐 기본값을 켤 때 한 번 알림
|
||
bool migrationNotice = true;
|
||
|
||
/// 34: Rsync · 동기화에서 원본이 대상 안 (안쪽 → 바깥) 을 허용 (지우기가 있을 때만 막음). 끄면 늘 막음
|
||
bool allowInnerToOuter = true;
|
||
|
||
/// 40 · 54: 비밀번호를 이 기기의 안전 저장소에 기억 (끄면 저장하지 않고 앱을 켤 때마다 다시 넣음)
|
||
bool rememberPasswords = true;
|
||
|
||
|
||
/// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다)
|
||
bool recycleOnDelete = true;
|
||
|
||
/// 47: MKV 목록에서 세 번 누르면 'move' (확인 뒤 이동 폴더로) · 'none' (아무것도 안 함)
|
||
String tripleTapAction = 'move';
|
||
|
||
/// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …)
|
||
String preferredSubtitleLanguage = '';
|
||
|
||
/// 55: 로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때 'ask' (매번 묻기) · 'fetch' (늘 받아서) · 'url' (늘 주소로)
|
||
String davExternalOpen = 'ask';
|
||
|
||
/// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로)
|
||
bool allFilesHintShown = false;
|
||
|
||
/// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1)
|
||
double subtitleScale = 1.0;
|
||
|
||
/// ZIP · CBZ 를 두 번 누르면 안의 그림을 만화처럼 보기 (끄면 목록). 기본은 목록 (사용자 지시)
|
||
bool zipComic = false;
|
||
|
||
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
|
||
List<DavServer> webdavServers = [];
|
||
|
||
/// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
||
List<SftpServer> sftpServers = [];
|
||
|
||
/// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다
|
||
List<SmbServer> smbServers = [];
|
||
|
||
/// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서).
|
||
/// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠
|
||
bool smbRequireSecure = false;
|
||
|
||
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
||
List<FtpServer> ftpServers = [];
|
||
|
||
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초).
|
||
/// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60
|
||
int remoteIdleSeconds = RemoteIdle.defaultSeconds;
|
||
|
||
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
|
||
|
||
/// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스)
|
||
String aiEngine = 'local';
|
||
|
||
/// 기기 안 처리 장치: 'auto' (잰 결과로 빠른 쪽) 또는 장치 열쇠 (예: 'engine-cuda:cuda0', 'engine-vulkan:cpu')
|
||
String aiDevice = 'auto';
|
||
|
||
/// 'auto' 가 잰 가장 빠른 장치와 그때의 장치 목록 (목록이 바뀌면 다시 잰다)
|
||
String aiAutoDevice = '';
|
||
String aiAutoDeviceFor = '';
|
||
|
||
/// 빠른 디코더 (TAESD) - 그림이 거의 같고 디코드가 10배쯤 빠르다 (받아 두었을 때)
|
||
bool aiTaesd = true;
|
||
int aiWidth = 512;
|
||
int aiHeight = 512;
|
||
int aiSteps = 4;
|
||
double aiCfg = 1;
|
||
int aiCount = 1;
|
||
double aiStrength = 0.6;
|
||
String aiNegative = '';
|
||
|
||
/// 159: 마지막 프롬프트와 만든 그림 목록 (최근 것부터, 업데이트 · 다시 열어도 남게)
|
||
String aiPrompt = '';
|
||
List<String> aiRecent = [];
|
||
|
||
/// 저장 폴더 (비어 있으면 사진 폴더의 JJ_MKVMaker_AI)
|
||
String aiSaveDir = '';
|
||
|
||
/// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에)
|
||
List<AiService> aiServices = [];
|
||
|
||
/// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음)
|
||
List<String> aiAgreed = [];
|
||
|
||
// 121: 해상도 올리기
|
||
/// 모델 'auto' (GPU 면 사진용, CPU 뿐이면 가볍고 빠른 만화용) · 'photo' · 'anime'
|
||
String aiUpModel = 'auto';
|
||
|
||
/// 176: 해상도 올리기 메모리 (Android CPU 타일 크기) - 'low' 20% · 'normal' 30% (처음 값) · 'high' 45% (기기 메모리 중)
|
||
String aiUpMemory = 'normal';
|
||
|
||
/// 156-2: 해상도 올리기에 걸린 시간 (처리 장치|모델 → 원본 100만 화소당 초) - "한 장 약 n초" 를 미리 알리려고
|
||
Map<String, double> aiUpSecPerMp = {};
|
||
|
||
/// 배율 2 · 3 · 4
|
||
int aiUpScale = 2;
|
||
|
||
/// 관리자 판단 (10/17): 보기 화면의 [품질 올리기] 단추 (그림 → 그림 약하게 + 해상도 올리기). 처음 값: 끔
|
||
bool aiQualityButton = false;
|
||
|
||
/// [품질 올리기] 다듬기 정도 (그림 → 그림 바꾸는 정도): 'weak' 0.15 · 'normal' 0.25 (처음 값) · 'strong' 0.4 (관리자 지시 - 판단은 설정으로)
|
||
String aiQualityStrength = 'normal';
|
||
|
||
/// 저장 형식 'same' (원본과 같게, 못 쓰는 형식은 PNG) · 'png' · 'jpg' · JPG 품질
|
||
String aiUpFormat = 'same';
|
||
int aiUpJpgQuality = 92;
|
||
|
||
/// 저장 위치 (비어 있으면 원본 옆 "이름_x2")
|
||
String aiUpDir = '';
|
||
|
||
/// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로
|
||
String aiUpAfter = 'upscaled';
|
||
|
||
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
|
||
int liveSyncIntervalSec = 30;
|
||
|
||
/// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시).
|
||
/// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]).
|
||
bool runInBackground = true;
|
||
|
||
/// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작)
|
||
String liveSyncOnStart = 'auto';
|
||
|
||
/// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground]
|
||
bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground;
|
||
set backgroundRun(bool v) {
|
||
if (Platform.isWindows) {
|
||
closeAction = v ? 'background' : 'quit';
|
||
} else {
|
||
runInBackground = v;
|
||
}
|
||
}
|
||
|
||
/// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창)
|
||
List<String> explorerPaths = [];
|
||
|
||
/// 최근에 연 폴더 (내역, 최신이 앞)
|
||
List<String> explorerHistory = [];
|
||
|
||
/// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음
|
||
String webViewDataDir = '';
|
||
|
||
/// 시작할 때 새 버전 확인 (하루 한 번)
|
||
bool autoCheckUpdates = true;
|
||
String lastUpdateCheck = '';
|
||
|
||
/// [lastUpdateCheck] 를 한 버전 (같은 설정 파일을 쓰는 다른 버전이 확인했으면 다시 확인)
|
||
String lastUpdateCheckVersion = '';
|
||
|
||
/// 마지막으로 이 설정을 쓴 (실행한) 버전. 예전 버전은 이 항목을 몰라 저장하면서 지운다 →
|
||
/// 돌아온 버전이 자기 것이 아니면 보관해 둔 그 버전의 설정을 되살릴지 묻는다 (VersionSnapshot)
|
||
String lastRunVersion = '';
|
||
|
||
/// P0 (Android 되돌리기): 받은 AI 모델을 공용 폴더에 옮겨 두었다가 되살릴지. '' = 남은 공간이 모델의 2배 이상이면 켬 · 'on' · 'off'
|
||
String rollbackKeepModels = '';
|
||
|
||
/// "이 버전 건너뛰기" 한 버전
|
||
String skippedVersion = '';
|
||
|
||
/// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈)
|
||
PlaylistMode playlistMode = PlaylistMode.series;
|
||
|
||
/// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로
|
||
Map<String, String> externalPlayers = {};
|
||
|
||
/// 마지막 인코딩 설정
|
||
EncodeSettings encode = const EncodeSettings();
|
||
|
||
/// 마지막 AI 자막 설정
|
||
String aiSource = 'und';
|
||
List<String> aiTargets = ['ko', 'en', 'ja'];
|
||
String aiWhisper = 'whisper-base';
|
||
|
||
AppSettings();
|
||
|
||
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
|
||
List<SettingsGroup> get groups => const [];
|
||
|
||
/// [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()],
|
||
'youtubeAdSkip': youtubeAdSkip,
|
||
'youtubeAdHide': youtubeAdHide,
|
||
'webPauseOnLeave': webPauseOnLeave,
|
||
'downloadRoot': downloadRoot,
|
||
'clipboardWatch': clipboardWatch,
|
||
'minimizeToTray': minimizeToTray,
|
||
'closeAction': closeAction,
|
||
'showHotkey': showHotkey,
|
||
'askAiOptions': askAiOptions,
|
||
'addFinishedDownloads': addFinishedDownloads,
|
||
'torrentStallSeconds': torrentStallSeconds,
|
||
// 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만
|
||
'openSubtitlesSaved': {
|
||
'key': openSubtitlesKey.isNotEmpty,
|
||
'user': openSubtitlesUser.isNotEmpty,
|
||
'password': openSubtitlesPassword.isNotEmpty,
|
||
},
|
||
if (plainSecrets) ...{
|
||
'openSubtitlesKey': openSubtitlesKey,
|
||
'openSubtitlesUser': openSubtitlesUser,
|
||
'openSubtitlesPassword': openSubtitlesPassword,
|
||
},
|
||
'ytContainer': ytContainer.name,
|
||
'ytQuality': ytQuality.name,
|
||
'ytPreferH264': ytPreferH264,
|
||
'ytExpandPlaylists': ytExpandPlaylists,
|
||
'ytCookiesBrowser': ytCookiesBrowser,
|
||
'loginCookieSites': loginCookieSites,
|
||
'cookieScopeV2': true,
|
||
'ytCookiesFile': ytCookiesFile,
|
||
'maxParallelDownloads': maxParallelDownloads,
|
||
'maxParallelJobs': maxParallelJobs,
|
||
'showLog': showLog,
|
||
'mkvCompactList': mkvCompactList,
|
||
'startScreen': startScreen,
|
||
'uiScale': uiScale,
|
||
'uiScaleDefault': uiScaleDefault,
|
||
'uiBrightness': uiBrightness,
|
||
'openFileAction': openFileAction,
|
||
'screenOrientation': screenOrientation,
|
||
// 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정)
|
||
'orientationV2': true,
|
||
'uiLanguage': uiLanguage,
|
||
'appIcon': appIcon,
|
||
'uiLanguagesAdded': uiLanguagesAdded,
|
||
'openFileWindow': openFileWindow,
|
||
'homeUrl': homeUrl,
|
||
'browserEngine': browserEngine,
|
||
'cefWhenElevated': cefWhenElevated,
|
||
'externalBrowser': externalBrowser,
|
||
'webTranslate': webTranslate,
|
||
'webJavaScript': webJavaScript,
|
||
'dnsMode': dnsMode,
|
||
'dnsPrimary': dnsPrimary,
|
||
'dnsSecondary': dnsSecondary,
|
||
'dnsDohUrl': dnsDohUrl,
|
||
'torKeepData': torKeepData,
|
||
'torIdleMinutes': torIdleMinutes,
|
||
'torHomeUrl': torHomeUrl,
|
||
'torJavaScript': torJavaScript,
|
||
'torNoticeSeen': torNoticeSeen,
|
||
'explorerLayout': explorerLayout,
|
||
'explorerOnePaneCopy': explorerOnePaneCopy,
|
||
'explorerRetapCollapse': explorerRetapCollapse,
|
||
'explorerTextScale': explorerTextScale,
|
||
'explorerRowSpacing': explorerRowSpacing,
|
||
'batchRenameLast': batchRenameLast,
|
||
'explorerRootsInTree': explorerRootsInTree,
|
||
'explorerAlwaysMarks': explorerAlwaysMarks,
|
||
'explorerSwipePanes': explorerSwipePanes,
|
||
'androidTrashDays': androidTrashDays,
|
||
'explorerClick': explorerClick,
|
||
// 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다
|
||
'explorerV2': true,
|
||
// E3 (10/22): 선택 규칙 (사용자 지시 모음) 의 처음 값으로 한 번 옮겼음
|
||
'explorerRulesV1': true,
|
||
'explorerStyle': explorerStyle,
|
||
'explorerOrientation': explorerOrientation,
|
||
'explorerOrientV3': true,
|
||
'explorerToolbar': explorerToolbar,
|
||
'explorerButtons': explorerButtons,
|
||
'explorerSort': explorerSort,
|
||
'explorerSortDesc': explorerSortDesc,
|
||
'explorerFolderSize': explorerFolderSize,
|
||
'explorerDropAction': explorerDropAction,
|
||
'explorerProgressHidden': explorerProgressHidden,
|
||
'zipLevel': zipLevel,
|
||
'wifiShareMinutes': wifiShareMinutes,
|
||
'wifiShareTailscale': wifiShareTailscale,
|
||
'wifiShareFolder': wifiShareFolder,
|
||
'explorerSortPerFolder': explorerSortPerFolder,
|
||
'explorerSortByKind': explorerSortByKind,
|
||
'explorerRemoteThumbs': explorerRemoteThumbs,
|
||
'explorerRemoteThumbCacheMb': explorerRemoteThumbCacheMb,
|
||
'explorerKeyOverrides': explorerKeyOverrides,
|
||
'vaultEnabled': vaultEnabled,
|
||
'shizukuEnabled': shizukuEnabled,
|
||
'rootEnabled': rootEnabled,
|
||
'vaultMasterCopy': vaultMasterCopy,
|
||
'vaultAutoLockSeconds': vaultAutoLockSeconds,
|
||
'explorerFolderSorts': explorerFolderSorts,
|
||
'explorerShowHidden': explorerShowHidden,
|
||
'explorerHiddenItems': explorerHiddenItems,
|
||
'explorerHideConfirm': explorerHideConfirm,
|
||
'explorerPaths': explorerPaths,
|
||
'copyMethodFile': copyMethodFile,
|
||
'copyConflict': copyConflict,
|
||
'mkvExisting': mkvExisting,
|
||
'copySkipNewer': copySkipNewer,
|
||
'copyMethodFolder': copyMethodFolder,
|
||
'rsyncPaths': rsyncPaths,
|
||
'components': components,
|
||
'aiImageV1': true,
|
||
'robocopyLinksV1': true,
|
||
'navOrder': navOrder,
|
||
'swipeNav': swipeNav,
|
||
'imageExts': imageExts,
|
||
'readerFit': readerFit,
|
||
'readerRtl': readerRtl,
|
||
'readerBrightness': readerBrightness,
|
||
'readerSystemBars': readerSystemBars,
|
||
'readerAutoSeconds': readerAutoSeconds,
|
||
'readerGallery': readerGallery,
|
||
'readerVolumeKeys': readerVolumeKeys,
|
||
'readerSplit': readerSplit,
|
||
'startMenuShortcut': startMenuShortcut,
|
||
'syncStopToast': syncStopToast,
|
||
'swipeWrap': swipeWrap,
|
||
'browserSwipeHinted': browserSwipeHinted,
|
||
'cleanupPrechecked': cleanupPrechecked,
|
||
'migrationNotice': migrationNotice,
|
||
'allowInnerToOuter': allowInnerToOuter,
|
||
'rememberPasswords': rememberPasswords,
|
||
'recycleOnDelete': recycleOnDelete,
|
||
'tripleTapAction': tripleTapAction,
|
||
'preferredSubtitleLanguage': preferredSubtitleLanguage,
|
||
'davExternalOpen': davExternalOpen,
|
||
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
|
||
'allFilesHint2': allFilesHintShown,
|
||
'subtitleScale': subtitleScale,
|
||
'zipComic': zipComic,
|
||
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
|
||
'zipComicV2': true,
|
||
'aiEngine': aiEngine,
|
||
'aiDevice': aiDevice,
|
||
'aiAutoDevice': aiAutoDevice,
|
||
'aiAutoDeviceFor': aiAutoDeviceFor,
|
||
'aiTaesd': aiTaesd,
|
||
'aiWidth': aiWidth,
|
||
'aiHeight': aiHeight,
|
||
'aiSteps': aiSteps,
|
||
'aiCfg': aiCfg,
|
||
'aiCount': aiCount,
|
||
'aiStrength': aiStrength,
|
||
'aiNegative': aiNegative,
|
||
'aiPrompt': aiPrompt,
|
||
'aiRecent': aiRecent,
|
||
'aiSaveDir': aiSaveDir,
|
||
'aiAgreed': aiAgreed,
|
||
'aiUpModel': aiUpModel,
|
||
'aiUpMemory': aiUpMemory,
|
||
'aiUpSecPerMp': aiUpSecPerMp,
|
||
'aiUpScale': aiUpScale,
|
||
'aiQualityButton': aiQualityButton,
|
||
'aiQualityStrength': aiQualityStrength,
|
||
'aiUpFormat': aiUpFormat,
|
||
'aiUpJpgQuality': aiUpJpgQuality,
|
||
'aiUpDir': aiUpDir,
|
||
'aiUpAfter': aiUpAfter,
|
||
'aiServices': [
|
||
for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey},
|
||
],
|
||
'webdavServers': [
|
||
for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password},
|
||
],
|
||
// 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음
|
||
'sftpServers': [for (final x in sftpServers) x.toJson()],
|
||
'smbServers': [for (final x in smbServers) x.toJson()],
|
||
'smbRequireSecure': smbRequireSecure,
|
||
'remoteIdleSeconds': remoteIdleSeconds,
|
||
'ftpServers': [for (final x in ftpServers) x.toJson()],
|
||
'rsyncOptions': rsyncOptions,
|
||
'robocopyOptions': robocopyOptions,
|
||
'copyRunMode': copyRunMode,
|
||
'copyBandwidthKBps': copyBandwidthKBps,
|
||
'rsyncSource': rsyncSource,
|
||
'rsyncPath': rsyncPath,
|
||
'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()],
|
||
'liveSyncIntervalSec': liveSyncIntervalSec,
|
||
'runInBackground': runInBackground,
|
||
'backgroundV2': true,
|
||
'liveSyncOnStart': liveSyncOnStart,
|
||
'copyTasks': [for (final x in copyTasks) x.toJson()],
|
||
'explorerHistory': explorerHistory,
|
||
'autoCheckUpdates': autoCheckUpdates,
|
||
'lastUpdateCheck': lastUpdateCheck,
|
||
'lastUpdateCheckVersion': lastUpdateCheckVersion,
|
||
'lastRunVersion': lastRunVersion,
|
||
'rollbackKeepModels': rollbackKeepModels,
|
||
'skippedVersion': skippedVersion,
|
||
'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);
|
||
}
|
||
return j;
|
||
}
|
||
|
||
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
|
||
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
|
||
|
||
/// 색 보정 값 (-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),
|
||
]
|
||
..youtubeAdSkip = j['youtubeAdSkip'] as bool? ?? true
|
||
..youtubeAdHide = j['youtubeAdHide'] as bool? ?? true
|
||
..webPauseOnLeave = j['webPauseOnLeave'] as bool? ?? true
|
||
..downloadRoot = j['downloadRoot'] as String?
|
||
..clipboardWatch = j['clipboardWatch'] as bool? ?? true
|
||
..minimizeToTray = j['minimizeToTray'] as bool? ?? true
|
||
..closeAction = const ['background', 'quit', 'ask'].contains(j['closeAction']) ? j['closeAction'] as String : 'background'
|
||
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
|
||
..askAiOptions = j['askAiOptions'] as bool? ?? true
|
||
..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true
|
||
..torrentStallSeconds = const [0, 30, 60, 120, 300].contains(j['torrentStallSeconds']) ? j['torrentStallSeconds'] as int : 60
|
||
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
|
||
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
|
||
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
|
||
..ytContainer = pick(YtContainer.values, j['ytContainer'], YtContainer.mp4)
|
||
..ytPreferH264 = j['ytPreferH264'] is bool ? j['ytPreferH264'] as bool : Platform.isAndroid
|
||
..ytQuality = pick(YtQuality.values, j['ytQuality'], YtQuality.best)
|
||
..ytExpandPlaylists = j['ytExpandPlaylists'] as bool? ?? true
|
||
..ytCookiesBrowser = j['ytCookiesBrowser'] as String? ?? internalBrowserCookies
|
||
..loginCookieSites = j['loginCookieSites'] is List
|
||
? [for (final x in j['loginCookieSites'] as List) if (loginCookieDomains.contains(x)) '$x']
|
||
: [...loginCookieDomains]
|
||
..ytCookiesFile = j['ytCookiesFile'] as String? ?? ''
|
||
..maxParallelDownloads = (j['maxParallelDownloads'] as num?)?.toInt() ?? 3
|
||
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
|
||
..showLog = j['showLog'] as bool? ?? true
|
||
..mkvCompactList = const ['auto', 'small', 'large'].contains(j['mkvCompactList']) ? j['mkvCompactList'] as String : 'auto'
|
||
..startScreen = j['startScreen'] as String? ?? 'home'
|
||
..uiScale = clampUiScale(j['uiScale'] as num?)
|
||
..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?)
|
||
..uiBrightness = uiBrightnessModes.contains(j['uiBrightness']) ? j['uiBrightness'] as String : 'dark'
|
||
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
|
||
..uiLanguage = j['uiLanguage'] as String? ?? 'ko'
|
||
..appIcon = appIconOf(j['appIcon'] as String?)
|
||
..uiLanguagesAdded = [for (final x in (j['uiLanguagesAdded'] as List?) ?? const []) '$x']
|
||
..screenOrientation = j['orientationV2'] == true && const ['landscape', 'portrait', 'auto'].contains(j['screenOrientation'])
|
||
? j['screenOrientation'] as String
|
||
: 'auto'
|
||
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
|
||
..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/'
|
||
..browserEngine = j['browserEngine'] as String? ?? 'edge'
|
||
..cefWhenElevated = j['cefWhenElevated'] == true
|
||
..externalBrowser = j['externalBrowser'] as String? ?? 'system'
|
||
..webTranslate = j['webTranslate'] as bool? ?? false
|
||
..webJavaScript = j['webJavaScript'] as bool? ?? true
|
||
// 값이 없거나 틀리면 처음 값 (사용자가 정한 기본: 직접 지정 8.8.8.8 · 8.8.4.4). 보조는 비워 둘 수 있다
|
||
..dnsMode = DnsConfig.modes.contains(j['dnsMode']) ? j['dnsMode'] as String : DnsConfig.defaultMode
|
||
..dnsPrimary = j['dnsPrimary'] is String && parseDnsServer(j['dnsPrimary'] as String) != null
|
||
? (j['dnsPrimary'] as String).trim()
|
||
: DnsConfig.defaultPrimary
|
||
..dnsSecondary = j['dnsSecondary'] is String
|
||
? (parseDnsServer(j['dnsSecondary'] as String) != null || (j['dnsSecondary'] as String).trim().isEmpty
|
||
? (j['dnsSecondary'] as String).trim()
|
||
: DnsConfig.defaultSecondary)
|
||
: DnsConfig.defaultSecondary
|
||
..dnsDohUrl = j['dnsDohUrl'] is String && isValidDohUrl(j['dnsDohUrl'] as String)
|
||
? (j['dnsDohUrl'] as String).trim()
|
||
: DnsConfig.defaultDohUrl
|
||
..torKeepData = j['torKeepData'] == true
|
||
..torIdleMinutes = j['torIdleMinutes'] is int && (j['torIdleMinutes'] as int) >= 0 && (j['torIdleMinutes'] as int) <= 60
|
||
? j['torIdleMinutes'] as int
|
||
: 2
|
||
..torHomeUrl = j['torHomeUrl'] is String && (j['torHomeUrl'] as String).trim().isNotEmpty
|
||
? (j['torHomeUrl'] as String).trim()
|
||
: defaultTorHome
|
||
..torJavaScript = j['torJavaScript'] != false
|
||
..torNoticeSeen = j['torNoticeSeen'] == true
|
||
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
|
||
..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip'
|
||
// E3: 규칙 이전 판이 저장한 false 는 예전 처음 값 - 한 번 규칙 3 (켬) 으로
|
||
..explorerRetapCollapse = j['explorerRulesV1'] == true ? j['explorerRetapCollapse'] != false : true
|
||
// 값이 없거나 틀리면 지금 그대로 (100% · 보통), 범위 밖은 끝으로
|
||
..explorerTextScale = j['explorerTextScale'] is num
|
||
? (j['explorerTextScale'] as num).round().clamp(explorerTextScaleMin, explorerTextScaleMax)
|
||
: 100
|
||
..explorerRowSpacing = const {'compact', 'normal', 'wide'}.contains(j['explorerRowSpacing']) ? j['explorerRowSpacing'] as String : 'normal'
|
||
..batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {}
|
||
..explorerRootsInTree = j['explorerRootsInTree'] != false
|
||
// E3: 규칙 이전 판이 저장한 true 는 예전 처음 값 - 한 번 규칙 4 (끔) 로
|
||
..explorerAlwaysMarks = j['explorerRulesV1'] == true && j['explorerAlwaysMarks'] == true
|
||
..explorerSwipePanes = j['explorerSwipePanes'] == true
|
||
..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 }
|
||
// E3 (규칙 7 · 결정 3): 처음 값 두 창. 규칙 이전 판의 'auto' 는 예전 처음 값이라 한 번 두 창으로
|
||
..explorerLayout = switch (j['explorerLayout']) {
|
||
'single' || 'split' => j['explorerLayout'] as String,
|
||
'auto' when j['explorerRulesV1'] == true => 'auto',
|
||
_ => 'dual',
|
||
}
|
||
// 규칙 2: 두 기기 모두 한 번 = 선택. 규칙 이전 Android 의 'open' 은 예전 처음 값이라 한 번 'select' 로
|
||
..explorerClick = j['explorerClick'] == 'open' && (j['explorerRulesV1'] == true || !Platform.isAndroid) ? 'open' : 'select'
|
||
..explorerStyle = const ['windows', 'totalcmd'].contains(j['explorerStyle']) ? j['explorerStyle'] as String : 'xplore'
|
||
// 118: 예전 기본 '화면 모양 따라' 는 한 번 좌우로 옮긴다 (사용자가 고른 위아래는 그대로)
|
||
..explorerOrientation = j['explorerOrientation'] == 'stacked'
|
||
? 'stacked'
|
||
: j['explorerOrientation'] == 'auto' && j['explorerOrientV3'] == true
|
||
? 'auto'
|
||
: 'side'
|
||
..explorerToolbar = const ['edge', 'hidden'].contains(j['explorerToolbar']) ? j['explorerToolbar'] as String : 'middle'
|
||
..explorerButtons = [for (final x in (j['explorerButtons'] as List?) ?? const []) '$x']
|
||
..explorerSort = const ['date', 'size', 'type'].contains(j['explorerSort']) ? j['explorerSort'] as String : 'name'
|
||
..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false
|
||
..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort'
|
||
..explorerSortPerFolder = j['explorerSortPerFolder'] == true
|
||
..explorerSortByKind = j['explorerSortByKind'] == true
|
||
..explorerRemoteThumbs = const {'off', 'wifi', 'always'}.contains(j['explorerRemoteThumbs']) ? j['explorerRemoteThumbs'] as String : 'wifi'
|
||
..vaultEnabled = j['vaultEnabled'] == true
|
||
..shizukuEnabled = j['shizukuEnabled'] == true
|
||
..rootEnabled = j['rootEnabled'] == true
|
||
..vaultMasterCopy = j['vaultMasterCopy'] == true
|
||
..vaultAutoLockSeconds = j['vaultAutoLockSeconds'] is num ? (j['vaultAutoLockSeconds'] as num).round().clamp(0, 3600) : 60
|
||
..explorerKeyOverrides = j['explorerKeyOverrides'] is Map
|
||
? {for (final e in (j['explorerKeyOverrides'] as Map).entries) if (e.key is String && e.value is String) e.key as String: e.value as String}
|
||
: {}
|
||
..explorerRemoteThumbCacheMb = j['explorerRemoteThumbCacheMb'] is num
|
||
? (j['explorerRemoteThumbCacheMb'] as num).round().clamp(10, 10000)
|
||
: 200
|
||
..explorerProgressHidden = j['explorerProgressHidden'] == true
|
||
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
|
||
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
|
||
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
|
||
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
|
||
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
|
||
..explorerFolderSorts = {
|
||
if (j['explorerFolderSorts'] is Map)
|
||
for (final e in (j['explorerFolderSorts'] as Map).entries)
|
||
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
||
}
|
||
..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false
|
||
..explorerHiddenItems = [for (final x in (j['explorerHiddenItems'] as List?) ?? const []) if (x is String && x.isNotEmpty) x]
|
||
..explorerHideConfirm = j['explorerHideConfirm'] as bool? ?? true
|
||
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x']
|
||
..copyMethodFile = _method(j['copyMethodFile'])
|
||
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
|
||
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
|
||
..copySkipNewer = j['copySkipNewer'] != false
|
||
..copyMethodFolder = _method(j['copyMethodFolder'])
|
||
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
|
||
..components = j['components'] is List
|
||
? [
|
||
// 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙).
|
||
// 화면 · 버튼은 아는 것만 쓴다 (AppComponent.pages · contains)
|
||
for (final x in j['components'] as List) if (x is String && x.isNotEmpty) x,
|
||
// 123: 새 화면 "AI 그림" 은 예전 설정에도 한 번 켜서 넣는다 (끄기: 환경 설정 > 컴포넌트)
|
||
if (j['aiImageV1'] != true && !(j['components'] as List).contains('aiimage')) 'aiimage',
|
||
]
|
||
: AppComponent.defaultInstalled
|
||
..navOrder = j['navOrder'] is List
|
||
// 225: 모르는 화면 id 도 순서에 그대로 (그 판으로 돌아가면 사용자가 정한 순서대로)
|
||
? [for (final x in j['navOrder'] as List) if (x is String && x.isNotEmpty) x]
|
||
: AppComponent.defaultOrder
|
||
..swipeNav = j['swipeNav'] as bool? ?? true
|
||
..imageExts = j['imageExts'] is List
|
||
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
|
||
: [...defaultImageExtensions]
|
||
..readerFit = j['readerFit'] == 'width' ? 'width' : 'page'
|
||
..readerRtl = j['readerRtl'] as bool? ?? false
|
||
..readerBrightness = ((j['readerBrightness'] as num?)?.toDouble() ?? 0).clamp(-0.7, 0.7)
|
||
..readerSystemBars = j['readerSystemBars'] != false
|
||
..readerAutoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60)
|
||
..readerGallery = j['readerGallery'] as bool? ?? false
|
||
..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false
|
||
..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto'
|
||
..startMenuShortcut = j['startMenuShortcut'] != false
|
||
..syncStopToast = j['syncStopToast'] != false
|
||
..swipeWrap = j['swipeWrap'] != false
|
||
..browserSwipeHinted = j['browserSwipeHinted'] == true
|
||
..cleanupPrechecked = j['cleanupPrechecked'] is List
|
||
? [for (final x in j['cleanupPrechecked'] as List) '$x']
|
||
: ['work', 'download']
|
||
..migrationNotice = j['migrationNotice'] != false
|
||
..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 : ''
|
||
..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'
|
||
..allFilesHintShown = j['allFilesHint2'] == true
|
||
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
|
||
..zipComic = j['zipComic'] == true && j['zipComicV2'] == true
|
||
..aiEngine = j['aiEngine'] as String? ?? 'local'
|
||
..aiDevice = j['aiDevice'] as String? ?? 'auto'
|
||
..aiAutoDevice = j['aiAutoDevice'] as String? ?? ''
|
||
..aiAutoDeviceFor = j['aiAutoDeviceFor'] as String? ?? ''
|
||
..aiTaesd = j['aiTaesd'] != false
|
||
..aiWidth = ((j['aiWidth'] as num?)?.toInt() ?? 512).clamp(64, 2048)
|
||
..aiHeight = ((j['aiHeight'] as num?)?.toInt() ?? 512).clamp(64, 2048)
|
||
..aiSteps = ((j['aiSteps'] as num?)?.toInt() ?? 4).clamp(1, 150)
|
||
..aiCfg = ((j['aiCfg'] as num?)?.toDouble() ?? 1).clamp(0, 30)
|
||
..aiCount = ((j['aiCount'] as num?)?.toInt() ?? 1).clamp(1, 100)
|
||
..aiStrength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1)
|
||
..aiNegative = j['aiNegative'] as String? ?? ''
|
||
..aiPrompt = j['aiPrompt'] as String? ?? ''
|
||
..aiRecent = [for (final x in (j['aiRecent'] as List?) ?? const []) '$x']
|
||
..aiSaveDir = j['aiSaveDir'] as String? ?? ''
|
||
..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x']
|
||
..aiUpModel = const ['photo', 'anime'].contains(j['aiUpModel']) ? j['aiUpModel'] as String : 'auto'
|
||
..aiUpMemory = const ['low', 'high'].contains(j['aiUpMemory']) ? j['aiUpMemory'] as String : 'normal'
|
||
..aiUpSecPerMp = {
|
||
if (j['aiUpSecPerMp'] case final Map m)
|
||
for (final e in m.entries)
|
||
if (e.value is num && (e.value as num) > 0) '${e.key}': (e.value as num).toDouble(),
|
||
}
|
||
// 1 = 크기는 그대로 선명하게만 (관리자 판단 10/17 - 사용자 질문에 답이 없어 넣되 처음 값은 2배 그대로)
|
||
..aiUpScale = const [1, 3, 4].contains(j['aiUpScale']) ? j['aiUpScale'] as int : 2
|
||
..aiQualityButton = j['aiQualityButton'] == true
|
||
..aiQualityStrength = const ['weak', 'normal', 'strong'].contains(j['aiQualityStrength']) ? j['aiQualityStrength'] as String : 'normal'
|
||
..aiUpFormat = const ['png', 'jpg'].contains(j['aiUpFormat']) ? j['aiUpFormat'] as String : 'same'
|
||
..aiUpJpgQuality = ((j['aiUpJpgQuality'] as num?)?.toInt() ?? 92).clamp(50, 100)
|
||
..aiUpDir = j['aiUpDir'] as String? ?? ''
|
||
..aiUpAfter = j['aiUpAfter'] == 'original' ? 'original' : 'upscaled'
|
||
..aiServices = [
|
||
for (final x in (j['aiServices'] as List?) ?? const [])
|
||
if (x is Map) AiService.fromJson(x),
|
||
]
|
||
..webdavServers = [
|
||
for (final x in (j['webdavServers'] as List?) ?? const [])
|
||
if (x is Map) DavServer.fromJson(x),
|
||
]
|
||
..sftpServers = [
|
||
for (final x in (j['sftpServers'] as List?) ?? const [])
|
||
if (x is Map) SftpServer.fromJson(x),
|
||
]
|
||
..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false
|
||
..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds
|
||
..smbServers = [
|
||
for (final x in (j['smbServers'] as List?) ?? const [])
|
||
if (x is Map) SmbServer.fromJson(x),
|
||
]
|
||
..ftpServers = [
|
||
for (final x in (j['ftpServers'] as List?) ?? const [])
|
||
if (x is Map) FtpServer.fromJson(x),
|
||
]
|
||
..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
|
||
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
|
||
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'
|
||
..copyBandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000)
|
||
..rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download'
|
||
..rsyncPath = j['rsyncPath'] as String? ?? ''
|
||
..liveSyncPairs = [
|
||
for (final x in (j['liveSyncPairs'] as List?) ?? const [])
|
||
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
|
||
]
|
||
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
|
||
// 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로
|
||
..runInBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true
|
||
..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto'
|
||
..copyTasks = [
|
||
for (final x in (j['copyTasks'] as List?) ?? const [])
|
||
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
|
||
]
|
||
..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x']
|
||
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
|
||
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
|
||
..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? ''
|
||
..lastRunVersion = j['lastRunVersion'] as String? ?? ''
|
||
..rollbackKeepModels = const ['on', 'off'].contains(j['rollbackKeepModels']) ? j['rollbackKeepModels'] as String : ''
|
||
..skippedVersion = j['skippedVersion'] as String? ?? ''
|
||
..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';
|
||
s.migrated = _migrated(j, s);
|
||
for (final g in s.groups) {
|
||
g.read(j);
|
||
}
|
||
s.extra = Map.of(j);
|
||
return s;
|
||
}
|
||
|
||
/// 업데이트하면서 예전 기본값을 새 기본값으로 옮긴 것 (예전 설정 파일에 그 값이 실제로 있었을 때만).
|
||
/// 앱이 켜질 때 한 번 알리고 (무엇이 바뀌었는지 · 환경 설정에서 되돌릴 수 있다는 것) 비운다. 저장하지 않는다.
|
||
List<String> migrated = [];
|
||
|
||
/// 읽은 설정 파일 그대로 (이 판이 모르는 항목을 저장할 때 지우지 않게)
|
||
Map<String, dynamic> extra = {};
|
||
|
||
/// 설정 파일에 평문으로 두면 안 되는 항목 (54)
|
||
static const secretKeys = ['openSubtitlesKey', 'openSubtitlesUser', 'openSubtitlesPassword'];
|
||
|
||
/// 설정 JSON 에서 비밀 값을 뺀 것 (.bak · 모르는 항목 보존용)
|
||
static Map<String, dynamic> stripSecrets(Map<String, dynamic> j) {
|
||
final r = Map<String, dynamic>.of(j);
|
||
for (final k in secretKeys) {
|
||
r.remove(k);
|
||
}
|
||
final servers = r['webdavServers'];
|
||
if (servers is List) {
|
||
r['webdavServers'] = [
|
||
for (final x in servers) x is Map ? (Map.of(x)..remove('password')) : x,
|
||
];
|
||
}
|
||
final sftp = r['sftpServers'];
|
||
if (sftp is List) {
|
||
r['sftpServers'] = [
|
||
for (final x in sftp) x is Map ? (Map.of(x)..remove('password')..remove('privateKey')..remove('keyPassphrase')) : x,
|
||
];
|
||
}
|
||
final smb = r['smbServers'];
|
||
if (smb is List) {
|
||
r['smbServers'] = [
|
||
for (final x in smb) x is Map ? (Map.of(x)..remove('password')) : x,
|
||
];
|
||
}
|
||
final ftp = r['ftpServers'];
|
||
if (ftp is List) {
|
||
r['ftpServers'] = [
|
||
for (final x in ftp) x is Map ? (Map.of(x)..remove('password')) : x,
|
||
];
|
||
}
|
||
final ai = r['aiServices'];
|
||
if (ai is List) {
|
||
r['aiServices'] = [
|
||
for (final x in ai) x is Map ? (Map.of(x)..remove('apiKey')) : x,
|
||
];
|
||
}
|
||
return r;
|
||
}
|
||
|
||
static List<String> _migrated(Map<String, dynamic> j, AppSettings s) => [
|
||
if (j['orientationV2'] != true && j['screenOrientation'] == 'landscape' && s.screenOrientation == 'auto')
|
||
'orientation',
|
||
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorerLayout == 'auto') 'explorerLayout',
|
||
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorerClick == 'open') 'explorerClick',
|
||
if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation',
|
||
if (j['aiImageV1'] != true && j['components'] is List && !(j['components'] as List).contains('aiimage')) 'aiImage',
|
||
// 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다
|
||
if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies)
|
||
'cookieScope',
|
||
if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.zipComic) 'zipComic',
|
||
if (j['backgroundV2'] != true && j['runInBackground'] == false && s.runInBackground && Platform.isAndroid)
|
||
'background',
|
||
// 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화.
|
||
// 예전 판에는 키가 없어 위의 "예전 값 → 새 값" 조건에 걸리지 않았다 (P11: 탐색기에서 한 번 눌렀는데 바로 열려 놀람)
|
||
if (j.isNotEmpty && !j.containsKey('explorerClick') && s.explorerClick == 'open') 'explorerClickNew',
|
||
// E3 (10/22): 선택 규칙의 처음 값으로 옮긴 것 - 규칙 이전 판에서 올라온 사람에게 한 번
|
||
if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerAlwaysMarks'] != false) 'explorerMarksRule',
|
||
if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerRetapCollapse'] != true) 'explorerRetapRule',
|
||
if (j.isNotEmpty && j['explorerRulesV1'] != true && Platform.isAndroid && j['explorerClick'] != 'select')
|
||
'explorerClickRule',
|
||
if (j.isNotEmpty && j['explorerRulesV1'] != true && (j['explorerLayout'] ?? 'auto') == 'auto') 'explorerLayoutRule',
|
||
if (j.isNotEmpty && !j.containsKey('screenOrientation') && s.screenOrientation == 'auto' && Platform.isAndroid) 'orientation',
|
||
if (j.isNotEmpty && !j.containsKey('swipeNav') && s.swipeNav) 'swipeNav',
|
||
if (j.isNotEmpty && !j.containsKey('runInBackground') && s.runInBackground && Platform.isAndroid) 'background',
|
||
// DNS 강제 (사용자 요청 10/10, 처음 값 직접 지정 8.8.8.8 · 8.8.4.4) 가 없던 판에서 올라오면 이름을 묻는 서버가 바뀐다 - 193 과 같이 한 번 알림
|
||
if (j.isNotEmpty && !j.containsKey('dnsMode') && s.dnsMode == 'custom') 'dnsForced',
|
||
// 193 · 229: 예전 판을 쓰던 사람 (설정 파일이 있는데 이 항목이 없음) 에게 복사 · 이동이 받는 쪽의 더 새 파일을 건너뛰게 바뀐 것을 한 번
|
||
if (j.isNotEmpty && !j.containsKey('copySkipNewer')) 'copySkipNewer',
|
||
// 193: robocopy 를 쓰던 사람 (Windows) 에게 링크 · junction 을 따라가지 않게 (/XJ) 바뀐 것을 한 번
|
||
if (j['robocopyLinksV1'] != true && Platform.isWindows && _usedRobocopy(j)) 'robocopyLinks',
|
||
];
|
||
|
||
/// 193: 예전 설정에서 robocopy 를 쓰고 있었는지 (복사 방법 · 기억한 복사 · 실시간 동기화 쌍)
|
||
static bool _usedRobocopy(Map<String, dynamic> j) {
|
||
bool robo(Object? x) => x is Map && x['method'] == 'robocopy';
|
||
return j['copyMethodFile'] == 'robocopy' ||
|
||
j['copyMethodFolder'] == 'robocopy' ||
|
||
((j['copyTasks'] as List?) ?? const []).any(robo) ||
|
||
((j['liveSyncPairs'] as List?) ?? const []).any(robo);
|
||
}
|
||
|
||
/// 다운로드 기본 위치: 프로그램 폴더 (쓸 수 없으면 사용자 다운로드 폴더)
|
||
String resolvedDownloadRoot() {
|
||
if (downloadRoot != null && downloadRoot!.isNotEmpty) return downloadRoot!;
|
||
// Android: 프로그램 폴더가 없으므로 내장 저장소의 Download\JJ_MKVMaker
|
||
if (Platform.isAndroid) return '/storage/emulated/0/Download/JJ_MKVMaker';
|
||
final appDir = p.dirname(Platform.resolvedExecutable);
|
||
try {
|
||
final probe = File(p.join(appDir, '.jj_write_test'));
|
||
probe.writeAsStringSync('x');
|
||
probe.deleteSync();
|
||
return appDir;
|
||
} catch (_) {
|
||
final home = Platform.environment['USERPROFILE'] ?? Platform.environment['HOME'] ?? appDir;
|
||
return p.join(home, 'Downloads');
|
||
}
|
||
}
|
||
|
||
String get ytDlpDir => p.join(resolvedDownloadRoot(), 'jj_yt-dlp');
|
||
|
||
/// 앱 안 브라우저가 내보낸 YouTube · Google 쿠키 (cookies.txt). 없으면 빈 값
|
||
String get internalCookieFile {
|
||
if (webViewDataDir.isEmpty) return '';
|
||
final f = p.join(webViewDataDir, 'cookies_youtube.txt');
|
||
return File(f).existsSync() ? f : '';
|
||
}
|
||
|
||
/// 앱 안 브라우저의 쿠키가 있는 프로필 폴더 (로그인한 적이 없으면 빈 값)
|
||
String get internalCookieProfile {
|
||
if (webViewDataDir.isEmpty) return '';
|
||
final profile = p.join(webViewDataDir, 'EBWebView', 'Default');
|
||
final hasCookies = File(p.join(profile, 'Network', 'Cookies')).existsSync() ||
|
||
File(p.join(profile, 'Cookies')).existsSync();
|
||
return hasCookies ? profile : '';
|
||
}
|
||
String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2');
|
||
}
|