다국어 작은 것들 (61)
- 영어 복수형: 영어 글에서 수가 1 이면 단수 ("1 videos" → "1 video"). 11 · 21 · 1.5 는 그대로.
- 탐색기 복사 · 이동 확인: 문장 조각 + "복사할까요?" 를 잇던 것을 두 문장으로 (언어마다 어순이 깨졌음).
- MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막: 늘 한국어 → 화면 언어 (목록에 없는 언어면 한국어).
- robocopy 진행: 종류 글 ("New File" 등) 이 아니라 탭 모양으로 파일 줄을 알아봐 일본어 · 중국어 Windows 에서도.
- Android 저장소 이름: 내장 · SD 카드는 앱 화면 언어로 (Kotlin 이 isPrimary 를 넘김), USB 등은 기기 이름 그대로.
- 기본 즐겨찾기 ("YouTube 구독" · 표시줄 · 기타): 한국어 열쇠로 저장하고 이름을 바꾸지 않았으면 화면 언어로
(예전 판이 다른 언어로 저장한 기본 이름 포함). 바꾼 이름 · 사용자 항목은 그대로.
- i18n_small_test · sync_tools_test (일본어 · 중국어 robocopy 줄), 캡처 시험에 일본어 가로 첫 화면.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
parent
f33a4b3547
commit
f429c59502
|
|
@ -340,6 +340,7 @@ class MainActivity : FlutterActivity() {
|
||||||
"path" to dir.absolutePath,
|
"path" to dir.absolutePath,
|
||||||
"label" to (v.getDescription(this) ?: dir.name),
|
"label" to (v.getDescription(this) ?: dir.name),
|
||||||
"removable" to v.isRemovable,
|
"removable" to v.isRemovable,
|
||||||
|
"primary" to v.isPrimary,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,8 @@
|
||||||
"{0}개 풀기": "Extract {0}",
|
"{0}개 풀기": "Extract {0}",
|
||||||
"{0}개 항목을 {1}": "{0} items {1}",
|
"{0}개 항목을 {1}": "{0} items {1}",
|
||||||
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}",
|
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}",
|
||||||
|
"{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "Copy {0} items to this folder?\n{1}",
|
||||||
|
"{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "Move {0} items to this folder?\n{1}",
|
||||||
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).",
|
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).",
|
||||||
"{0}개 항목을 되돌렸습니다.": "Restored {0} item(s).",
|
"{0}개 항목을 되돌렸습니다.": "Restored {0} item(s).",
|
||||||
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.",
|
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.",
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,8 @@
|
||||||
"{0}개 풀기": "{0} 個を展開",
|
"{0}개 풀기": "{0} 個を展開",
|
||||||
"{0}개 항목을 {1}": "{0} 件の項目を{1}",
|
"{0}개 항목을 {1}": "{0} 件の項目を{1}",
|
||||||
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}",
|
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}",
|
||||||
|
"{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "{0} 件の項目を次のフォルダーへコピーしますか?\n{1}",
|
||||||
|
"{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "{0} 件の項目を次のフォルダーへ移動しますか?\n{1}",
|
||||||
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
|
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
|
||||||
"{0}개 항목을 되돌렸습니다.": "{0} 件を元に戻しました。",
|
"{0}개 항목을 되돌렸습니다.": "{0} 件を元に戻しました。",
|
||||||
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。",
|
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。",
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,8 @@
|
||||||
"{0}개 풀기": "解压 {0} 个",
|
"{0}개 풀기": "解压 {0} 个",
|
||||||
"{0}개 항목을 {1}": "{0} 个项目{1}",
|
"{0}개 항목을 {1}": "{0} 个项目{1}",
|
||||||
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}",
|
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}",
|
||||||
|
"{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "要将 {0} 个项目复制到以下文件夹吗?\n{1}",
|
||||||
|
"{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "要将 {0} 个项目移动到以下文件夹吗?\n{1}",
|
||||||
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。",
|
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。",
|
||||||
"{0}개 항목을 되돌렸습니다.": "已恢复 {0} 个项目。",
|
"{0}개 항목을 되돌렸습니다.": "已恢复 {0} 个项目。",
|
||||||
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。",
|
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。",
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,19 @@ void main() {
|
||||||
await t.runAsync(() => i18n.apply('ko', save: false));
|
await t.runAsync(() => i18n.apply('ko', save: false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 61: 일본어 가로 (태블릿 크기) 에서 맨 위 버튼 글이 잘리는지 - 첫 화면 (MKV) 과 각 화면의 맨 위 줄
|
||||||
|
for (final size in [const Size(1280, 800), const Size(1138, 712), const Size(960, 600)]) {
|
||||||
|
testWidgets('61: 일본어 가로 ${size.width.toInt()}x${size.height.toInt()} - 맨 위 버튼', (t) async {
|
||||||
|
t.view.physicalSize = size;
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
await t.runAsync(() => i18n.apply('ja', save: false));
|
||||||
|
await t.runAsync(() => t.pumpWidget(RepaintBoundary(key: shotKey, child: JjMkvMakerApp(controller: c, navigatorKey: navKey))));
|
||||||
|
await settle(t, 20);
|
||||||
|
await shot(t, 'i18n_ja_home_${size.width.toInt()}');
|
||||||
|
await t.runAsync(() => i18n.apply('ko', save: false));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,38 @@ import 'dart:math' as math;
|
||||||
/// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음)
|
/// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음)
|
||||||
class BookmarkNode {
|
class BookmarkNode {
|
||||||
final String id;
|
final String id;
|
||||||
String title;
|
|
||||||
|
/// 저장된 이름 그대로 (기본 즐겨찾기는 한국어 열쇠)
|
||||||
|
String rawTitle;
|
||||||
String? url;
|
String? url;
|
||||||
final List<BookmarkNode>? children;
|
final List<BookmarkNode>? children;
|
||||||
|
|
||||||
BookmarkNode.link(this.id, this.title, String this.url) : children = null;
|
BookmarkNode.link(this.id, this.rawTitle, String this.url) : children = null;
|
||||||
BookmarkNode.folder(this.id, this.title, [List<BookmarkNode>? items])
|
BookmarkNode.folder(this.id, this.rawTitle, [List<BookmarkNode>? items])
|
||||||
: url = null,
|
: url = null,
|
||||||
children = items ?? [];
|
children = items ?? [];
|
||||||
|
|
||||||
|
/// 화면에 보일 이름. 61: 기본 즐겨찾기 ("YouTube 구독" · 표시줄 · 기타) 는 이름을 바꾸지 않았으면 지금 화면 언어로
|
||||||
|
/// (처음 켤 때의 언어로 저장돼 그 뒤 언어를 바꿔도 그대로였다). 사용자가 바꾼 이름은 그대로.
|
||||||
|
String get title {
|
||||||
|
final names = _defaultNames[id];
|
||||||
|
return names != null && names.contains(rawTitle) ? tr(names.first) : rawTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
set title(String v) => rawTitle = v;
|
||||||
|
|
||||||
|
/// 기본 즐겨찾기의 이름: 한국어 열쇠 (맨 앞) 와 예전 판이 저장했을 수 있는 번역 (en · ja · zh-Hans)
|
||||||
|
static const _defaultNames = {
|
||||||
|
'b2': ['YouTube 구독', 'YouTube subscriptions', 'YouTube 登録チャンネル', 'YouTube 订阅'],
|
||||||
|
BookmarkTree.barId: ['즐겨찾기 표시줄', 'Bookmarks bar', 'ブックマークバー', '书签栏'],
|
||||||
|
BookmarkTree.otherId: ['기타 즐겨찾기', 'Other bookmarks', 'その他のブックマーク', '其他书签'],
|
||||||
|
};
|
||||||
|
|
||||||
bool get isFolder => children != null;
|
bool get isFolder => children != null;
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'title': title,
|
'title': rawTitle,
|
||||||
if (url != null) 'url': url,
|
if (url != null) 'url': url,
|
||||||
if (children != null) 'children': [for (final c in children!) c.toJson()],
|
if (children != null) 'children': [for (final c in children!) c.toJson()],
|
||||||
};
|
};
|
||||||
|
|
@ -43,12 +61,13 @@ class BookmarkTree {
|
||||||
|
|
||||||
/// 처음 쓰는 경우 기본 즐겨찾기
|
/// 처음 쓰는 경우 기본 즐겨찾기
|
||||||
factory BookmarkTree.defaults() => BookmarkTree(
|
factory BookmarkTree.defaults() => BookmarkTree(
|
||||||
BookmarkNode.folder(barId, tr('즐겨찾기 표시줄'), [
|
// 이름은 한국어 열쇠로 저장하고 보일 때 화면 언어로 (BookmarkNode.title)
|
||||||
|
BookmarkNode.folder(barId, '즐겨찾기 표시줄', [
|
||||||
BookmarkNode.link('b1', 'YouTube', 'https://www.youtube.com/'),
|
BookmarkNode.link('b1', 'YouTube', 'https://www.youtube.com/'),
|
||||||
BookmarkNode.link('b2', tr('YouTube 구독'), 'https://www.youtube.com/feed/subscriptions'),
|
BookmarkNode.link('b2', 'YouTube 구독', 'https://www.youtube.com/feed/subscriptions'),
|
||||||
BookmarkNode.link('b3', 'OpenSubtitles', 'https://www.opensubtitles.com/'),
|
BookmarkNode.link('b3', 'OpenSubtitles', 'https://www.opensubtitles.com/'),
|
||||||
]),
|
]),
|
||||||
BookmarkNode.folder(otherId, tr('기타 즐겨찾기')),
|
BookmarkNode.folder(otherId, '기타 즐겨찾기'),
|
||||||
);
|
);
|
||||||
|
|
||||||
String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}';
|
String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}';
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,13 @@ Language languageOf(String? token) {
|
||||||
// MKV 태그의 639-2/T 표기 (zho, deu 등)는 별칭에 포함되어 있음
|
// MKV 태그의 639-2/T 표기 (zho, deu 등)는 별칭에 포함되어 있음
|
||||||
return undetermined;
|
return undetermined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 61: 플레이어가 먼저 켤 자막을 알아보는 글 (화면 언어의 자막: 파일 이름의 코드 · 트랙 이름). 예전에는 늘 한국어.
|
||||||
|
/// 화면 언어가 자막 언어 목록에 없으면 한국어.
|
||||||
|
RegExp preferredSubtitlePattern([String? ui]) {
|
||||||
|
var l = languageOf(ui ?? uiLanguage);
|
||||||
|
if (l == undetermined) l = languageOf('ko');
|
||||||
|
final codes = {l.code.toLowerCase(), l.mkv, ...l.aliases.where((a) => RegExp(r'^[a-z-]+$').hasMatch(a))}.map(RegExp.escape).join('|');
|
||||||
|
final names = {l.koName, l.name, ...l.aliases.where((a) => !RegExp(r'^[a-z-]+$').hasMatch(a))}.map(RegExp.escape).join('|');
|
||||||
|
return RegExp('[._-]($codes)[._]|\\(($codes)\\)|\\b(${l.mkv})\\b|$names', caseSensitive: false);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'encode_options.dart';
|
import 'encode_options.dart';
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
import 'languages.dart';
|
||||||
|
import '../l10n/tr.dart';
|
||||||
|
|
||||||
/// MKV 에 그대로 복사 가능한 자막 코덱
|
/// MKV 에 그대로 복사 가능한 자막 코덱
|
||||||
const _copyableSubtitleCodecs = {
|
const _copyableSubtitleCodecs = {
|
||||||
|
|
@ -13,8 +15,12 @@ String _externalCodec(String ext) => switch (ext) {
|
||||||
_ => 'srt', // srt, smi, sami, vtt
|
_ => 'srt', // srt, smi, sami, vtt
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 기본 자막으로 표시할 언어 (MKV 태그)
|
/// 기본 자막으로 표시할 언어 (MKV 태그). 61: 화면 언어를 따른다 (예전에는 늘 한국어 'kor').
|
||||||
const preferredDefaultLanguage = 'kor';
|
/// 화면 언어가 자막 언어 목록에 없으면 한국어.
|
||||||
|
String get preferredDefaultLanguage {
|
||||||
|
final l = languageOf(uiLanguage);
|
||||||
|
return l == undetermined ? 'kor' : l.mkv;
|
||||||
|
}
|
||||||
|
|
||||||
/// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수.
|
/// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -197,9 +197,10 @@ class RobocopyOutput {
|
||||||
final _buf = StringBuffer();
|
final _buf = StringBuffer();
|
||||||
double? currentPercent;
|
double? currentPercent;
|
||||||
|
|
||||||
static final _file = RegExp(
|
/// 파일 줄: 탭 · 종류 · 탭 탭 · 크기 · 탭 · 경로. 61: 종류 글은 Windows 언어마다 다르므로
|
||||||
r'^\s*(New File|Newer|Older|Changed|Same|Tweaked|Modified|\*EXTRA File|새 파일|최신|이전|변경됨|같음)\s+[\d.]+\s*[kmgt]?\s+(.+)$',
|
/// ("New File" · "새 파일" · "新しいファイル" · "新文件") 글이 아니라 탭 모양으로 알아본다.
|
||||||
caseSensitive: false);
|
/// 종류가 * 로 시작하면 (*EXTRA File 등) 대상에만 있는 것이라 세지 않는다.
|
||||||
|
static final _file = RegExp(r'^\t+\s*([^\t\s\d][^\t]*?)\s*\t+\s*[\d.]+\s*[kmgt]?\s*\t(.+)$', caseSensitive: false);
|
||||||
|
|
||||||
List<String> feed(String chunk) {
|
List<String> feed(String chunk) {
|
||||||
_buf.write(chunk);
|
_buf.write(chunk);
|
||||||
|
|
@ -218,7 +219,7 @@ class RobocopyOutput {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final m = _file.firstMatch(raw);
|
final m = _file.firstMatch(raw);
|
||||||
if (m != null && !m[1]!.toLowerCase().startsWith('*extra')) {
|
if (m != null && !m[1]!.startsWith('*')) {
|
||||||
currentPercent = null;
|
currentPercent = null;
|
||||||
done.add(m[2]!.trim());
|
done.add(m[2]!.trim());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,26 @@ String tr(String ko) {
|
||||||
final _slot = RegExp(r'\{(\d+)\}');
|
final _slot = RegExp(r'\{(\d+)\}');
|
||||||
|
|
||||||
/// 자리 표시 ({0}, {1} …) 가 있는 글자: `trf('동영상 {0}개', [n])`
|
/// 자리 표시 ({0}, {1} …) 가 있는 글자: `trf('동영상 {0}개', [n])`
|
||||||
String trf(String ko, List<Object?> args) => tr(ko).replaceAllMapped(_slot, (m) {
|
String trf(String ko, List<Object?> args) {
|
||||||
final i = int.parse(m.group(1)!);
|
final s = tr(ko).replaceAllMapped(_slot, (m) {
|
||||||
return i < args.length ? '${args[i]}' : m.group(0)!;
|
final i = int.parse(m.group(1)!);
|
||||||
});
|
return i < args.length ? '${args[i]}' : m.group(0)!;
|
||||||
|
});
|
||||||
|
// 61: 영어 글이면 "1 videos" → "1 video" (영어 사전 · 영어로 대신 보인 글)
|
||||||
|
final english = _lang == 'en' || (_lang != 'ko' && !_dict.containsKey(ko) && _fallback.containsKey(ko));
|
||||||
|
return english ? englishSingular(s) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 수가 1 인 영어 복수 명사를 단수로 ("1 videos" · "(1 files)" → "1 video" · "(1 file)"). 11 · 21 · 1.5 는 그대로.
|
||||||
|
String englishSingular(String s) => s.replaceAllMapped(_one, (m) => '${m[1]}1 ${_singularOf[m[2]!]}');
|
||||||
|
|
||||||
|
const _singularOf = {
|
||||||
|
'videos': 'video', 'files': 'file', 'items': 'item', 'subtitles': 'subtitle', 'downloads': 'download',
|
||||||
|
'images': 'image', 'lines': 'line', 'results': 'result', 'characters': 'character', 'syncs': 'sync',
|
||||||
|
'MKVs': 'MKV', 'links': 'link', 'folders': 'folder', 'pages': 'page', 'times': 'time', 'pairs': 'pair',
|
||||||
|
'servers': 'server', 'tracks': 'track', 'languages': 'language', 'models': 'model', 'bookmarks': 'bookmark',
|
||||||
|
'errors': 'error', 'seconds': 'second', 'minutes': 'minute', 'hours': 'hour', 'days': 'day', 'tasks': 'task',
|
||||||
|
'copies': 'copy', 'entries': 'entry', 'episodes': 'episode', 'changes': 'change', 'components': 'component',
|
||||||
|
};
|
||||||
|
|
||||||
|
final _one = RegExp('(^|[^\\d.,])1 (${_singularOf.keys.join('|')})\\b');
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ class AndroidAccess {
|
||||||
final list = await _ch.invokeMethod<List<Object?>>('storageVolumes') ?? const [];
|
final list = await _ch.invokeMethod<List<Object?>>('storageVolumes') ?? const [];
|
||||||
final out = [
|
final out = [
|
||||||
for (final v in list.cast<Map<Object?, Object?>>())
|
for (final v in list.cast<Map<Object?, Object?>>())
|
||||||
(v['path'] as String, v['label'] as String? ?? '', v['removable'] == true),
|
(v['path'] as String, volumeLabel(v['label'] as String? ?? '', primary: v['primary'] == true, removable: v['removable'] == true),
|
||||||
|
v['removable'] == true),
|
||||||
];
|
];
|
||||||
out.sort((a, b) => (a.$3 ? 1 : 0) - (b.$3 ? 1 : 0));
|
out.sort((a, b) => (a.$3 ? 1 : 0) - (b.$3 ? 1 : 0));
|
||||||
if (out.isNotEmpty) return out;
|
if (out.isNotEmpty) return out;
|
||||||
|
|
@ -44,6 +45,14 @@ class AndroidAccess {
|
||||||
return [(await storageRoot(), tr('내장 저장소'), false)];
|
return [(await storageRoot(), tr('내장 저장소'), false)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 61: 저장소 이름을 앱 화면 언어로. 기기가 주는 이름 ("디바이스 저장공간" · "SD 카드") 은 기기 언어라
|
||||||
|
/// 내장 저장소 · SD 카드는 앱의 글로 바꾸고, 그 밖 (USB 메모리 등) 은 기기 이름 그대로.
|
||||||
|
static String volumeLabel(String device, {required bool primary, required bool removable}) {
|
||||||
|
if (primary) return tr('내장 저장소');
|
||||||
|
if (removable && !device.toUpperCase().contains('USB')) return tr('SD 카드');
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
/// 내장 저장소 맨 위 (예: /storage/emulated/0)
|
/// 내장 저장소 맨 위 (예: /storage/emulated/0)
|
||||||
static Future<String> storageRoot() async {
|
static Future<String> storageRoot() async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1727,7 +1727,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: Text(move ? tr('이동') : tr('복사')),
|
title: Text(move ? tr('이동') : tr('복사')),
|
||||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}'
|
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
|
||||||
|
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
|
||||||
'\n${_namesOf(sources)}'
|
'\n${_namesOf(sources)}'
|
||||||
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}'
|
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}'
|
||||||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import '../app/app_controller.dart';
|
import '../app/app_controller.dart';
|
||||||
|
import '../core/languages.dart';
|
||||||
import '../core/playlist.dart';
|
import '../core/playlist.dart';
|
||||||
import '../core/srt.dart' show formatSrtTime;
|
import '../core/srt.dart' show formatSrtTime;
|
||||||
import '../services/media_player.dart';
|
import '../services/media_player.dart';
|
||||||
|
|
@ -102,15 +103,15 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||||
c.externalSubtitlesFor(pl.playlist[i]).then((s) async {
|
c.externalSubtitlesFor(pl.playlist[i]).then((s) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _externalSubs = s);
|
setState(() => _externalSubs = s);
|
||||||
// 켜진 자막이 없으면 하나를 켠다 (한국어 먼저, 없으면 첫 번째). mpv 가 같은 폴더 자막을 불러와도
|
// 켜진 자막이 없으면 하나를 켠다 (화면 언어의 자막 먼저 - 61, 없으면 첫 번째). mpv 가 같은 폴더 자막을 불러와도
|
||||||
// 켜지 않는 일이 있어 (Android) 그 뒤에 (1초) 확인한다.
|
// 켜지 않는 일이 있어 (Android) 그 뒤에 (1초) 확인한다.
|
||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
final st = pl.state.value;
|
final st = pl.state.value;
|
||||||
if (!mounted || st.index != i || st.subtitleId != null) return;
|
if (!mounted || st.index != i || st.subtitleId != null) return;
|
||||||
final all = _subtitleChoices();
|
final all = _subtitleChoices();
|
||||||
if (all.isEmpty) return;
|
if (all.isEmpty) return;
|
||||||
final ko = RegExp(r'[._-](ko|kor)[._]|한국어|\(ko\)|\bkor\b', caseSensitive: false);
|
final mine = preferredSubtitlePattern();
|
||||||
await _setSub(all.firstWhere((t) => ko.hasMatch(t.label) || ko.hasMatch(p.basename(t.file ?? '')),
|
await _setSub(all.firstWhere((t) => mine.hasMatch(t.label) || mine.hasMatch(p.basename(t.file ?? '')),
|
||||||
orElse: () => all.first));
|
orElse: () => all.first));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/bookmarks.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/languages.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/mkv_command_builder.dart';
|
||||||
|
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/android/android_storage.dart';
|
||||||
|
|
||||||
|
/// 61: 다국어 작은 것들
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
tearDown(() => i18n.apply('ko', save: false));
|
||||||
|
|
||||||
|
test('영어 복수형: 수가 1 이면 단수 ("1 videos" → "1 video"), 11 · 21 · 1.5 는 그대로', () {
|
||||||
|
expect(englishSingular('Added 1 videos to the list.'), 'Added 1 video to the list.');
|
||||||
|
expect(englishSingular('(1 files)'), '(1 file)');
|
||||||
|
expect(englishSingular('1 copies · 1 entries'), '1 copy · 1 entry');
|
||||||
|
expect(englishSingular('11 videos · 21 files · 1.1 videos · 0,1 items'), '11 videos · 21 files · 1.1 videos · 0,1 items');
|
||||||
|
expect(englishSingular('2 videos'), '2 videos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('영어 화면에서 trf 가 단수를 쓴다 (한국어 · 일본어는 그대로)', () async {
|
||||||
|
await i18n.apply('en', save: false);
|
||||||
|
expect(trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [1, 'D:']), 'Move 1 item to this folder?\nD:');
|
||||||
|
expect(trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [3, 'D:']), 'Move 3 items to this folder?\nD:');
|
||||||
|
await i18n.apply('ja', save: false);
|
||||||
|
expect(trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [1, 'D:']), contains('1 件'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MKV 기본 자막 · 플레이어가 먼저 켤 자막은 화면 언어 (목록에 없는 언어면 한국어)', () async {
|
||||||
|
expect(preferredDefaultLanguage, 'kor');
|
||||||
|
await i18n.apply('ja', save: false);
|
||||||
|
expect(preferredDefaultLanguage, 'jpn');
|
||||||
|
final ja = preferredSubtitlePattern();
|
||||||
|
expect(ja.hasMatch('movie.ja.srt'), isTrue);
|
||||||
|
expect(ja.hasMatch('movie.jpn.srt'), isTrue);
|
||||||
|
expect(ja.hasMatch('日本語'), isTrue, reason: '트랙 이름 (화면 언어로 된 언어 이름)');
|
||||||
|
expect(ja.hasMatch('movie.ko.srt'), isFalse);
|
||||||
|
await i18n.apply('en', save: false);
|
||||||
|
expect(preferredDefaultLanguage, 'eng');
|
||||||
|
expect(preferredSubtitlePattern().hasMatch('Movie.en.srt'), isTrue);
|
||||||
|
expect(preferredSubtitlePattern().hasMatch('Movie.kor.srt'), isFalse);
|
||||||
|
// 한국어 (예전 그대로)
|
||||||
|
final ko = preferredSubtitlePattern('ko');
|
||||||
|
for (final s in ['a.ko.srt', 'a.kor.ass', 'a (ko).srt', '한국어']) {
|
||||||
|
expect(ko.hasMatch(s), isTrue, reason: s);
|
||||||
|
}
|
||||||
|
expect(preferredSubtitlePattern('xx').hasMatch('a.ko.srt'), isTrue, reason: '모르는 언어면 한국어');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Android 저장소 이름: 내장 · SD 카드는 앱 화면 언어로, USB 등은 기기 이름 그대로', () async {
|
||||||
|
await i18n.apply('en', save: false);
|
||||||
|
expect(AndroidAccess.volumeLabel('디바이스 저장공간', primary: true, removable: false), 'Internal storage');
|
||||||
|
expect(AndroidAccess.volumeLabel('SD 카드', primary: false, removable: true), 'SD card');
|
||||||
|
expect(AndroidAccess.volumeLabel('USB 저장소', primary: false, removable: true), 'USB 저장소');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('기본 즐겨찾기 이름은 바꾸지 않았으면 화면 언어로 (예전 판이 다른 언어로 저장했어도), 바꾼 이름은 그대로', () async {
|
||||||
|
final t = BookmarkTree.defaults();
|
||||||
|
expect(t.bar.children![1].title, 'YouTube 구독');
|
||||||
|
await i18n.apply('en', save: false);
|
||||||
|
expect(t.bar.children![1].title, 'YouTube subscriptions');
|
||||||
|
expect(t.bar.title, 'Bookmarks bar');
|
||||||
|
// 예전 판이 일본어로 저장해 둔 기본 이름
|
||||||
|
final old = BookmarkTree.fromJson({
|
||||||
|
'bar': {'id': 'bar', 'title': 'ブックマークバー', 'children': [
|
||||||
|
{'id': 'b2', 'title': 'YouTube 登録チャンネル', 'url': 'https://www.youtube.com/feed/subscriptions'},
|
||||||
|
{'id': 'n1', 'title': 'YouTube 구독', 'url': 'https://x'},
|
||||||
|
]},
|
||||||
|
'other': {'id': 'other', 'title': '내 것', 'children': []},
|
||||||
|
});
|
||||||
|
expect(old.bar.title, 'Bookmarks bar');
|
||||||
|
expect(old.bar.children![0].title, 'YouTube subscriptions');
|
||||||
|
expect(old.bar.children![1].title, 'YouTube 구독', reason: '기본이 아닌 항목 (사용자가 만든 것) 은 그대로');
|
||||||
|
expect(old.other.title, '내 것', reason: '바꾼 이름은 그대로');
|
||||||
|
// 저장은 원래 이름 그대로 (언어를 바꿔도 저장 파일이 바뀌지 않게)
|
||||||
|
expect((t.toJson()['bar'] as Map)['title'], '즐겨찾기 표시줄');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,18 @@ void main() {
|
||||||
expect(o.currentPercent, 0.45);
|
expect(o.currentPercent, 0.45);
|
||||||
expect(o.feed('100% \n\t Newer \t\t 200\tC:\\t\\src\\b.txt\n\t*EXTRA File \t\t 1\tC:\\x\n'), [r'C:\t\src\b.txt']);
|
expect(o.feed('100% \n\t Newer \t\t 200\tC:\\t\\src\\b.txt\n\t*EXTRA File \t\t 1\tC:\\x\n'), [r'C:\t\src\b.txt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('61: robocopy 파일 줄은 Windows 언어와 상관없이 (일본어 · 중국어 · 한국어 종류 글)', () {
|
||||||
|
final o = RobocopyOutput();
|
||||||
|
expect(o.feed('\t 新しいファイル \t\t 3000000\tC:\\t\\a b.txt\r\n 34% \r\n'), [r'C:\t\a b.txt']);
|
||||||
|
expect(o.currentPercent, 0.34);
|
||||||
|
expect(o.feed('\t 新文件 \t\t 1\tC:\\t\\c.txt\n\t 새 파일 \t\t 12.5 m\tC:\\t\\d.mkv\n'), [r'C:\t\c.txt', r'C:\t\d.mkv']);
|
||||||
|
// 대상에만 있는 것 (* 로 시작) · 폴더 줄 (종류 없이 수 · 경로) · 오류 줄은 세지 않는다
|
||||||
|
expect(
|
||||||
|
o.feed('\t*追加ファイル \t\t 1\tC:\\x\n\t\t\t\t 2\tC:\\t\\sub\\\n'
|
||||||
|
'2026/10/10 01:00:00 ERROR 5 (0x00000005) Copying File C:\\t\\e.txt\n'),
|
||||||
|
isEmpty);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('실제 복사', () {
|
group('실제 복사', () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue