설정 A: 재생 분야 (PlaybackSettings) - 동영상 열기 · 재생 목록 · 외부 플레이어 · 자막 크기 · 기본 자막 언어

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 00:12:48 +09:00
parent 7fe2f0562f
commit e828227693
13 changed files with 88 additions and 66 deletions

View File

@ -153,9 +153,9 @@ class AppController extends ChangeNotifier {
}
void _applySettings() {
playerSubtitleScale.value = settings.subtitleScale;
playerSubtitleScale.value = settings.playback.subtitleScale;
// 61: 기본 자막 언어 (MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막)
preferredSubtitleSetting = settings.preferredSubtitleLanguage;
preferredSubtitleSetting = settings.playback.preferredSubtitleLanguage;
// WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다)
DavRegistry.configure(settings.remote.webdavServers);
// 88: SFTP 서버 (sftp:// 경로)
@ -1294,7 +1294,7 @@ class AppController extends ChangeNotifier {
final videos = files.where((f) => isVideoFile(f) || isAudioFile(f)).toList();
if (videos.isEmpty) return null;
final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase();
final program = settings.externalPlayers[ext];
final program = settings.playback.externalPlayers[ext];
if (!internal && program != null && program.isNotEmpty) {
// WebDAV 는 스트리밍 주소로 넘긴다 (아이디 · 비밀번호는 넣지 않음 - 55)
if (external != null) {
@ -1312,7 +1312,7 @@ class AppController extends ChangeNotifier {
final siblings = isDav(videos.first)
? [for (final e in await vList(vDirname(videos.first))) if (!e.isDir) e.path]
: await services.storage.listFiles(p.dirname(videos.first));
return buildPlaylist(videos.first, siblings, settings.playlistMode);
return buildPlaylist(videos.first, siblings, settings.playback.playlistMode);
}
/// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt

View File

@ -4,9 +4,9 @@ import 'package:path/path.dart' as p;
import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/download_detect.dart';
import '../core/playlist.dart';
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/playback_settings.dart';
import 'settings/mkv_settings.dart';
import 'settings/display_settings.dart';
import 'settings/remote_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/playback_settings.dart';
export 'settings/mkv_settings.dart';
export 'settings/display_settings.dart';
export 'settings/remote_settings.dart';
@ -71,15 +72,11 @@ class AppSettings {
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
String openFileAction = 'play';
/// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창
String openFileWindow = 'same';
@ -181,15 +178,11 @@ class AppSettings {
bool recycleOnDelete = true;
/// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …)
String preferredSubtitleLanguage = '';
/// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로)
bool allFilesHintShown = false;
/// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1)
double subtitleScale = 1.0;
@ -232,11 +225,7 @@ class AppSettings {
/// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈)
PlaylistMode playlistMode = PlaylistMode.series;
/// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로
Map<String, String> externalPlayers = {};
@ -284,8 +273,11 @@ class AppSettings {
/// MKV · 자막 만들기 (저장 위치 · 이동 버튼 · 인코딩 · AI 자막 · OpenSubtitles)
final mkv = MkvSettings();
/// 재생 (동영상 열기 · 재생 목록 · 외부 플레이어 · 자막 크기 · 기본 자막 언어)
final playback = PlaybackSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display, mkv];
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display, mkv, playback];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -294,8 +286,6 @@ class AppSettings {
...stripSecrets(extra),
'folderBookmarks': [for (final b in folderBookmarks) b.toJson()],
'cookieScopeV2': true,
'openFileAction': openFileAction,
'openFileWindow': openFileWindow,
'robocopyLinksV1': true,
'imageExts': imageExts,
'cleanupPrechecked': cleanupPrechecked,
@ -303,12 +293,8 @@ class AppSettings {
'allowInnerToOuter': allowInnerToOuter,
'rememberPasswords': rememberPasswords,
'recycleOnDelete': recycleOnDelete,
'preferredSubtitleLanguage': preferredSubtitleLanguage,
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
'allFilesHint2': allFilesHintShown,
'subtitleScale': subtitleScale,
'playlistMode': playlistMode.name,
'externalPlayers': externalPlayers,
};
for (final g in groups) {
g.write(j, plainSecrets: plainSecrets);
@ -317,12 +303,8 @@ class AppSettings {
}
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 s = AppSettings()
..folderBookmarks = [for (final x in (j['folderBookmarks'] as List?) ?? const []) ?FolderBookmark.fromJson(x)]
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...defaultImageExtensions]
@ -333,11 +315,7 @@ class AppSettings {
..allowInnerToOuter = j['allowInnerToOuter'] != false
..rememberPasswords = j['rememberPasswords'] != false
..recycleOnDelete = j['recycleOnDelete'] != false
..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>();
..allFilesHintShown = j['allFilesHint2'] == true;
// 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다
for (final g in s.groups) {
g.read(j);

View File

@ -0,0 +1,44 @@
import '../../core/playlist.dart';
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 재생 (동영상 열기 · 재생 목록 · 외부 플레이어 · 자막 크기 · 기본 자막 언어) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class PlaybackSettings implements SettingsGroup {
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
String openFileAction = 'play';
/// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창
String openFileWindow = 'same';
/// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …)
String preferredSubtitleLanguage = '';
/// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1)
double subtitleScale = 1.0;
/// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈)
PlaylistMode playlistMode = PlaylistMode.series;
/// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로
Map<String, String> externalPlayers = {};
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['openFileAction'] = openFileAction;
j['openFileWindow'] = openFileWindow;
j['playlistMode'] = playlistMode.name;
j['externalPlayers'] = externalPlayers;
j['subtitleScale'] = subtitleScale;
j['preferredSubtitleLanguage'] = preferredSubtitleLanguage;
}
@override
void read(Map<String, dynamic> j) {
openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play';
openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same';
preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : '';
subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5);
playlistMode = pickEnum(PlaylistMode.values, j['playlistMode'], PlaylistMode.series);
externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>();
}
}

View File

@ -367,7 +367,7 @@ Future<void> main(List<String> args) async {
if (videos.isEmpty) return;
// 어느 쪽이든 MKV 화면의 목록에는 들어간다
final added = controller.addVideos(videos, allowOutputFolder: true);
if (controller.settings.openFileAction == 'add') {
if (controller.settings.playback.openFileAction == 'add') {
await added;
final first = controller.videos.where((v) => videos.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) controller.select(first);
@ -444,7 +444,7 @@ Future<void> openFromOtherApp(
if (ctx == null || !ctx.mounted) return;
if (paths.isNotEmpty) {
final added = c.addVideos(paths, allowOutputFolder: true);
if (c.settings.openFileAction == 'add') {
if (c.settings.playback.openFileAction == 'add') {
await added;
final first = c.videos.where((v) => paths.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) c.select(first);
@ -535,9 +535,9 @@ void watchDnsFailure(GlobalKey<ScaffoldMessengerState> messengerKey, AppControll
/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서")
bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) =>
s.openFileWindow == 'new' &&
s.playback.openFileWindow == 'new' &&
r.files.any(isVideoFile) &&
(r.action == LaunchAction.play || (r.action == LaunchAction.add && s.openFileAction == 'play'));
(r.action == LaunchAction.play || (r.action == LaunchAction.add && s.playback.openFileAction == 'play'));
extension on String {
String ifEmptyText(String other) => isEmpty ? other : this;

View File

@ -405,8 +405,8 @@ class _PlayerPageState extends State<PlayerPage> {
// 자막 글자 크기 (설정에 남는다)
SubmenuButton(menuChildren: [
for (final r in const [0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5])
item('${(r * 100).round()}%', () => c.updateSettings((x) => x.subtitleScale = r),
checked: (c.settings.subtitleScale - r).abs() < 0.01),
item('${(r * 100).round()}%', () => c.updateSettings((x) => x.playback.subtitleScale = r),
checked: (c.settings.playback.subtitleScale - r).abs() < 0.01),
], child: Text(tr('자막 크기'))),
if (s.audioTracks.length > 1)
SubmenuButton(menuChildren: [

View File

@ -42,8 +42,8 @@ class _MkvSettingsSectionState extends State<MkvSettingsSection> {
title: Text(tr('기본 자막 언어')),
subtitle: Text(tr('MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막')),
trailing: SubtitleLanguagePicker(
value: s.preferredSubtitleLanguage,
onChanged: (v) => c.updateSettings((x) => x.preferredSubtitleLanguage = v),
value: s.playback.preferredSubtitleLanguage,
onChanged: (v) => c.updateSettings((x) => x.playback.preferredSubtitleLanguage = v),
),
),
SettingTile(
@ -219,11 +219,11 @@ class _PlaySettingsSectionState extends State<PlaySettingsSection> {
title: Text(tr('동영상 하나를 재생할 때')),
subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')),
trailing: DropdownButton<PlaylistMode>(
value: s.playlistMode,
value: s.playback.playlistMode,
items: [
for (final m in PlaylistMode.values) DropdownMenuItem(value: m, child: Text(m.label)),
],
onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!),
onChanged: (m) => c.updateSettings((x) => x.playback.playlistMode = m!),
),
),
SettingTile(
@ -232,12 +232,12 @@ class _PlaySettingsSectionState extends State<PlaySettingsSection> {
? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)')
: tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')),
trailing: DropdownButton<String>(
value: s.openFileAction,
value: s.playback.openFileAction,
items: [
DropdownMenuItem(value: 'play', child: Text(tr('바로 재생 (기본)'))),
DropdownMenuItem(value: 'add', child: Text(tr('편집 목록에 추가 (MKV 만들기 대상)'))),
],
onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!),
onChanged: (v) => c.updateSettings((x) => x.playback.openFileAction = v!),
),
),
if (desk) ...[
@ -245,12 +245,12 @@ class _PlaySettingsSectionState extends State<PlaySettingsSection> {
title: Text(tr('탐색기에서 연 동영상을 재생할 창')),
subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')),
trailing: DropdownButton<String>(
value: s.openFileWindow,
value: s.playback.openFileWindow,
items: [
DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))),
DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))),
],
onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!),
onChanged: (v) => c.updateSettings((x) => x.playback.openFileWindow = v!),
),
),
ExternalPlayersTile(c: c),
@ -316,13 +316,13 @@ class ExternalPlayersTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final map = c.settings.externalPlayers;
final map = c.settings.playback.externalPlayers;
final vlc = c.services.shell.vlcPath;
Future<void> set(String ext, String? v) => c.updateSettings((x) {
if (v == null || v.isEmpty) {
x.externalPlayers.remove(ext);
x.playback.externalPlayers.remove(ext);
} else {
x.externalPlayers[ext] = v;
x.playback.externalPlayers[ext] = v;
}
});

View File

@ -373,7 +373,7 @@ void main() {
File(p.join(root.path, '영상', '메모.txt')).writeAsStringSync('t');
addTearDown(RemoteStreamBridge.close);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
c.settings.playback.playlistMode = PlaylistMode.folder;
final plan = await c.preparePlayback(['ftp://f1/영상/1화.mkv']);
expect(plan!.$1, ['ftp://f1/영상/1화.mkv', 'ftp://f1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');

View File

@ -55,7 +55,7 @@ void main() {
test('기본 자막 언어 설정: 화면 언어 따르기 (처음 값) · 고른 언어가 화면 언어보다 먼저 · 저장', () async {
addTearDown(() => preferredSubtitleSetting = '');
expect(AppSettings().preferredSubtitleLanguage, '', reason: '처음 값은 화면 언어 따르기');
expect(AppSettings().playback.preferredSubtitleLanguage, '', reason: '처음 값은 화면 언어 따르기');
await i18n.apply('ja', save: false);
preferredSubtitleSetting = 'ko'; // 화면은 일본어, 자막은 한국어
expect(preferredDefaultLanguage, 'kor');
@ -65,8 +65,8 @@ void main() {
expect(preferredDefaultLanguage, 'vie');
preferredSubtitleSetting = '';
expect(preferredDefaultLanguage, 'jpn');
final s = AppSettings()..preferredSubtitleLanguage = 'zh-Hans';
expect(AppSettings.fromJson(s.toJson()).preferredSubtitleLanguage, 'zh-Hans');
final s = AppSettings()..playback.preferredSubtitleLanguage = 'zh-Hans';
expect(AppSettings.fromJson(s.toJson()).playback.preferredSubtitleLanguage, 'zh-Hans');
});
test('Android 저장소 이름: 내장 · SD 카드는 앱 화면 언어로, USB 등은 기기 이름 그대로', () async {

View File

@ -147,18 +147,18 @@ void main() {
final play = LaunchRequest.parse(['--play', r'D:.mkv']);
final sub = LaunchRequest.parse(['--subtitle', r'D:.mkv']);
final s = AppSettings();
expect([s.openFileAction, s.openFileWindow], ['play', 'same']);
expect([s.playback.openFileAction, s.playback.openFileWindow], ['play', 'same']);
expect(wantsNewPlayerWindow(open, s), isFalse); // 기본: 켜져 있는 창에서
s.openFileWindow = 'new';
s.playback.openFileWindow = 'new';
expect(wantsNewPlayerWindow(open, s), isTrue);
expect(wantsNewPlayerWindow(play, s), isTrue);
expect(wantsNewPlayerWindow(sub, s), isFalse); // 자막 만들기는 원래 창
expect(wantsNewPlayerWindow(LaunchRequest.parse(const []), s), isFalse);
s.openFileAction = 'add';
s.playback.openFileAction = 'add';
expect(wantsNewPlayerWindow(open, s), isFalse); // 목록에 추가는 원래 창
expect(wantsNewPlayerWindow(play, s), isTrue);
final back = AppSettings.fromJson(s.toJson());
expect([back.openFileAction, back.openFileWindow], ['add', 'new']);
expect([back.playback.openFileAction, back.playback.openFileWindow], ['add', 'new']);
});
test('Android 화면 방향: 기본 자동, 가로 · 세로 고정 저장, 예전 기본 (가로 고정) 은 한 번 자동으로', () {

View File

@ -85,7 +85,7 @@ void main() {
test('같은 폴더 동영상 목록 · 다리로 스트리밍 (Range · 건너뛰기) · 다음 파일 · 그 자막', () async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
c.settings.playback.playlistMode = PlaylistMode.folder;
final plan = await c.preparePlayback(['sftp://s1/영상/1화.mkv']);
expect(plan!.$1, ['sftp://s1/영상/1화.mkv', 'sftp://s1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로');

View File

@ -16,12 +16,12 @@ void main() {
setUp(() => c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())));
test('자막 글자 크기: 설정에 남고 플레이어에 바로 반영', () async {
await c.updateSettings((s) => s.subtitleScale = 1.5);
await c.updateSettings((s) => s.playback.subtitleScale = 1.5);
expect(playerSubtitleScale.value, 1.5);
expect(AppSettings.fromJson(c.settings.toJson()).subtitleScale, 1.5);
expect(AppSettings.fromJson({'subtitleScale': 9}).subtitleScale, 2.5);
expect(AppSettings.fromJson({}).subtitleScale, 1.0);
await c.updateSettings((s) => s.subtitleScale = 1.0);
expect(AppSettings.fromJson(c.settings.toJson()).playback.subtitleScale, 1.5);
expect(AppSettings.fromJson({'subtitleScale': 9}).playback.subtitleScale, 2.5);
expect(AppSettings.fromJson({}).playback.subtitleScale, 1.0);
await c.updateSettings((s) => s.playback.subtitleScale = 1.0);
});
test('WebDAV 가 끼면 탐색기 복사 확인 창에 robocopy 가 아니라 실제 방법 (현재 방식)', () {

View File

@ -323,7 +323,7 @@ void main() {
});
addTearDown(RemoteStreamBridge.close);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
c.settings.playback.playlistMode = PlaylistMode.folder;
final plan = await c.preparePlayback(['smb://m1/영상/1화.mkv']);
expect(plan!.$1, ['smb://m1/영상/1화.mkv', 'smb://m1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');

View File

@ -186,7 +186,7 @@ void main() {
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
http.close();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
c.settings.playback.playlistMode = PlaylistMode.folder;
final before = server.requests.where((r) => r.startsWith('GET')).length;
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);