diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index fd176aa..fe1e589 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -27,6 +27,8 @@ class MainActivity : FlutterActivity() { result.success(null) } "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) + "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) + "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) "keepAlive" -> { keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1) result.success(null) @@ -44,6 +46,35 @@ class MainActivity : FlutterActivity() { } } + /// 웹 주소를 기기의 기본 브라우저로 + private fun openUrl(url: String): Boolean = try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + } + + /// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false. + private fun openFolder(path: String): Boolean { + val root = Environment.getExternalStorageDirectory().path + if (!path.startsWith(root)) return false + val rel = path.removePrefix(root).trim('/') + val uri = android.provider.DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "primary:$rel" + ) + for (type in listOf(android.provider.DocumentsContract.Document.MIME_TYPE_DIR, "resource/folder")) { + try { + startActivity( + Intent(Intent.ACTION_VIEW).setDataAndType(uri, type) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + ) + return true + } catch (e: Exception) { + } + } + return false + } + /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) private var askedNotifications = false diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 6013f27..888b4bb 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -483,16 +483,32 @@ class AppController extends ChangeNotifier { } final tmp = p.join(await storage.tempDirectory(), 'edit_${DateTime.now().microsecondsSinceEpoch}.srt'); + final copy = await _utf8Copy(s); try { await _tool.runFfmpeg(s.kind == SubtitleKind.external - ? buildToSrtArgs(input: s.path!, output: tmp, charset: s.charset) + ? buildToSrtArgs(input: copy ?? s.path!, output: tmp, charset: copy == null ? s.charset : null) : buildToSrtArgs(input: v.path, output: tmp, streamIndex: s.streamIndex)); return parseSrt(decodeText(await storage.readBytes(tmp), 'UTF-8')); } finally { await storage.delete(tmp); + if (copy != null) await storage.delete(copy); } } + /// UTF-8 이 아닌 외부 자막 (CP949 SMI 등) 의 UTF-8 임시 사본. 필요 없으면 null. + /// Android 의 FFmpeg (ffmpeg-kit) 에는 문자셋 변환 (iconv) 이 없어 -sub_charenc 를 쓸 수 없으므로 + /// 앱이 바꿔서 넘긴다 (Windows 도 같은 방식으로). + Future _utf8Copy(SubtitleEntry s) async { + final cs = s.charset; + if (s.kind != SubtitleKind.external || s.path == null || cs == null || cs.startsWith('UTF-')) return null; + final storage = services.storage; + final text = decodeText(await storage.readBytes(s.path!), cs); + final copy = p.join(await storage.tempDirectory(), + 'utf8_${DateTime.now().microsecondsSinceEpoch}_${p.basename(s.path!)}'); + await storage.writeBytes(copy, utf8.encode(text)); + return copy; + } + /// 편집한 자막을 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 에 반영되도록 목록을 바꾼다. /// - 내장 자막: 원래 트랙은 '삭제' 표시, 편집본을 외부 자막으로 추가 /// - 외부 자막: 같은 자리의 항목을 편집본으로 교체 (원본 파일은 그대로 둠) @@ -981,6 +997,32 @@ class AppController extends ChangeNotifier { } /// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt + /// 플레이어에 넘길 자막 파일 (Android). Android 의 mpv 는 문자셋 변환 (iconv) 이 없고 SAMI (SMI) 도 열지 못하므로 + /// UTF-8 이 아닌 자막은 UTF-8 사본으로, SMI 는 SRT 로 바꿔 임시 폴더에 만든다. 그 밖에는 그대로. + Future playableSubtitle(String path) async { + if (!Platform.isAndroid) return path; + try { + final storage = services.storage; + final ext = p.extension(path).toLowerCase(); + final isSami = ext == '.smi' || ext == '.sami'; + final cs = detectCharset(Uint8List.fromList(await storage.readHead(path, 64 * 1024))); + if (cs == 'UTF-8' && !isSami) return path; + final tmp = await storage.tempDirectory(); + final key = path.hashCode.toUnsigned(32); + var src = path; + if (cs != 'UTF-8') { + src = p.join(tmp, 'play_${key}_${p.basename(path)}'); + await storage.writeBytes(src, utf8.encode(decodeText(await storage.readBytes(path), cs))); + } + if (!isSami) return src; + final srt = p.join(tmp, 'play_$key.srt'); + await _tool.runFfmpeg(buildToSrtArgs(input: src, output: srt)); + return srt; + } catch (_) { + return path; + } + } + Future> externalSubtitlesFor(String video) async { final out = []; final storage = services.storage; @@ -1043,10 +1085,23 @@ class AppController extends ChangeNotifier { notifyListeners(); _log('시작: ${v.fileName} → $outputFolderName\\${p.basename(out)}' '${encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}]' : ''}'); + final copies = {}; + var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지 try { + // Android 의 FFmpeg (ffmpeg-kit) 에는 AV1 디코더가 없어 다시 인코딩할 수 없다 (원본 유지는 된다) + final vcodec = v.info?.ofType('video').firstOrNull?.codec; + if (Platform.isAndroid && encode.reencode && vcodec == 'av1') { + throw const MediaToolException('AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. ' + '코덱을 "원본 유지" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)'); + } await services.storage.ensureDirectory(outputDirFor(v.path)); + for (final s in v.subtitles.where((s) => s.enabled)) { + final c = await _utf8Copy(s); + if (c != null) copies[s] = c; + } + started = true; await _tool.runFfmpeg( - buildMuxArgs(v, out, encode: encode, encoders: encoders), + buildMuxArgs(v, out, encode: encode, encoders: encoders, utf8Copies: copies), duration: v.info?.duration, onProgress: (x) { v.progress = x; @@ -1067,6 +1122,18 @@ class AppController extends ChangeNotifier { ..status = JobStatus.failed ..message = msg; _log('실패: ${v.fileName}\n$msg'); + // 만들다 만 파일은 지운다 (0 바이트 MKV 가 남지 않도록). 시작 전에 실패했으면 전에 만든 MKV 는 그대로 둔다. + if (started) { + try { + await services.storage.delete(out); + } catch (_) {} + } + } finally { + for (final c in copies.values) { + try { + await services.storage.delete(c); + } catch (_) {} + } } notifyListeners(); } diff --git a/lib/core/mkv_command_builder.dart b/lib/core/mkv_command_builder.dart index e424dc3..a3816ff 100644 --- a/lib/core/mkv_command_builder.dart +++ b/lib/core/mkv_command_builder.dart @@ -19,11 +19,14 @@ const preferredDefaultLanguage = 'kor'; /// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수. /// /// [encode] 가 재인코딩이면 영상은 선택한 코덱·크기로 인코딩하고, 음성은 항상 그대로 복사한다. +/// +/// [utf8Copies]: UTF-8 로 바꿔 둔 외부 자막 사본 (원래 항목 → 사본 경로). 사본은 문자셋 지정 없이 읽는다. List buildMuxArgs( VideoItem video, String outputPath, { EncodeSettings encode = const EncodeSettings(), Set encoders = const {}, + Map utf8Copies = const {}, }) { final externals = video.subtitles .where((s) => s.enabled && s.kind == SubtitleKind.external) @@ -39,12 +42,13 @@ List buildMuxArgs( // 입력 1..n: 외부 자막 (-sub_charenc 는 입력 옵션이므로 -i 앞에 둔다) for (final s in externals) { + final copy = utf8Copies[s]; final cs = s.charset; // UTF-8 / UTF-16(BOM) 은 FFmpeg 가 스스로 처리 - if (cs != null && !cs.startsWith('UTF-')) { + if (copy == null && cs != null && !cs.startsWith('UTF-')) { args.addAll(['-sub_charenc', cs]); } - args.addAll(['-i', s.path!]); + args.addAll(['-i', copy ?? s.path!]); } // 영상(표지 그림 제외)·음성 트랙 전부, 첨부 파일(폰트) 유지 diff --git a/lib/platform/android/android_shell.dart b/lib/platform/android/android_shell.dart index 38ec167..6c0bb94 100644 --- a/lib/platform/android/android_shell.dart +++ b/lib/platform/android/android_shell.dart @@ -1,9 +1,13 @@ +import 'dart:io'; + import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; import '../../services/app_shell.dart'; -/// Android: 창 · 트레이 · 단축키 · 탐색기 연결은 없음. 종료와 전체 화면만. +/// Android: 창 · 트레이 · 단축키 · 탐색기 연결은 없음. 종료 · 전체 화면 · 웹 주소 열기 · 폴더 열기 (파일 앱). class AndroidShell extends NoopShell { + static const _ch = MethodChannel('jj_mkvmaker/android'); bool _full = false; @override @@ -17,4 +21,21 @@ class AndroidShell extends NoopShell { @override Future isFullScreen() async => _full; + + /// 기기의 기본 브라우저로 (고른 브라우저는 PC 에서만) + @override + Future openUrl(String url, {String browser = 'system'}) async { + try { + await _ch.invokeMethod('openUrl', {'url': url}); + } catch (_) {} + } + + /// 파일이 있는 폴더 (폴더면 그 폴더) 를 파일 앱으로 + @override + Future revealFile(String path) async { + final dir = await FileSystemEntity.isDirectory(path) ? path : p.dirname(path); + try { + await _ch.invokeMethod('openFolder', {'path': dir}); + } catch (_) {} + } } diff --git a/lib/platform/android/android_storage.dart b/lib/platform/android/android_storage.dart index 89bfe9a..e423898 100644 --- a/lib/platform/android/android_storage.dart +++ b/lib/platform/android/android_storage.dart @@ -58,10 +58,20 @@ class AndroidStorageService extends DesktopStorageService { Future> pickSubtitles({String? initialDirectory}) => _pick('자막 파일 선택', subtitleExtensions, initialDirectory: initialDirectory); + /// 임시 폴더: 캐시 폴더가 아니라 앱 데이터 폴더 아래 tmp. + /// 저장 공간이 모자라면 Android 가 캐시 폴더를 마음대로 비워, 쓰는 중인 임시 파일 (AI 음성 · 자막 사본) 이 + /// 사라질 수 있다. 대신 앱을 켤 때 (처음 쓸 때) 지난 실행의 임시 파일을 지운다. + static Future? _tmp; + @override - Future tempDirectory() async { - final dir = Directory(p.join((await getTemporaryDirectory()).path, 'jj_mkvmaker')); - await dir.create(recursive: true); - return dir.path; - } + Future tempDirectory() => _tmp ??= () async { + final dir = Directory(p.join((await getApplicationSupportDirectory()).path, 'tmp')); + if (await dir.exists()) { + try { + await dir.delete(recursive: true); + } catch (_) {} + } + await dir.create(recursive: true); + return dir.path; + }(); } diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index f463dd1..5421ddc 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -14,7 +14,8 @@ import '../../services/media_player.dart'; bool isNonFatalMpvMessage(String m) => m.contains('Could not open codec') || m.contains('hardware decoding') || - m.contains('Could not create device'); + m.contains('Could not create device') || + m.contains('Can not open external file'); /// mpv(media_kit) 동영상 플레이어. Windows·Android 공용. class MediaKitFullPlayer implements MediaPlayer { @@ -87,7 +88,8 @@ class MediaKitFullPlayer implements MediaPlayer { audioTracks: audio, subtitleTracks: subs, audioId: st.track.audio.id, - subtitleId: _externalSub?.id ?? (sid == 'no' ? null : sid), + // mpv 의 'auto' 는 고른 자막이 없을 수도 있다 → 실제 목록에 있을 때만 켜진 것으로 + subtitleId: _externalSub?.id ?? (subs.any((t) => t.id == sid) ? sid : null), ); } diff --git a/lib/platform/windows/aria2_backend.dart b/lib/platform/windows/aria2_backend.dart index 87e3ae5..686266f 100644 --- a/lib/platform/windows/aria2_backend.dart +++ b/lib/platform/windows/aria2_backend.dart @@ -198,7 +198,10 @@ class Aria2Backend implements DownloadBackend { } catch (_) {} } _tasks.remove(t.id); - t.state = DownloadState.cancelled; + t + ..state = DownloadState.cancelled + ..speed = '' + ..eta = ''; await Future.delayed(const Duration(milliseconds: 300)); // 파일 핸들 해제 대기 for (final f in t.files) { for (final path in [f, '$f.aria2']) { diff --git a/lib/platform/windows/ytdlp_backend.dart b/lib/platform/windows/ytdlp_backend.dart index 5f28b1d..eb3739c 100644 --- a/lib/platform/windows/ytdlp_backend.dart +++ b/lib/platform/windows/ytdlp_backend.dart @@ -272,7 +272,10 @@ class YtDlpBackend implements DownloadBackend { @override Future cancel(DownloadTask t) async { await _stop(t); - t.state = DownloadState.cancelled; + t + ..state = DownloadState.cancelled + ..speed = '' + ..eta = ''; for (final f in t.files) { for (final path in [f, '$f.part', '$f.ytdl']) { try { diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index bdd6881..9885b34 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -232,7 +232,12 @@ class _DownloadRow extends StatelessWidget { tooltip: '폴더 열기', icon: const Icon(Icons.folder_open, size: 18, color: JjColors.textDim), onPressed: () { - if (Platform.isWindows) Process.run('explorer', [t.dir]); + if (Platform.isWindows) { + Process.run('explorer', [t.dir]); + } else { + // Android: 파일 앱으로 + AppScope.maybeOf(context)?.controller.services.shell.revealFile(t.dir); + } }, ), ]), diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 425bc5d..ca17fbc 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import '../app/app_controller.dart'; @@ -834,7 +836,7 @@ class _SubtitleRow extends StatelessWidget { ), if (c.services.createTranslator != null) IconButton( - tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 PC 에서)' : '이미지 자막은 번역할 수 없습니다', + tooltip: c.canEdit(s) ? '다른 언어로 번역 (AI · 이 ${Platform.isAndroid ? '기기' : 'PC'} 에서)' : '이미지 자막은 번역할 수 없습니다', iconSize: 18, onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), icon: const Icon(Icons.translate, color: JjColors.accent), diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 60d8f8d..aeec025 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -92,8 +92,17 @@ class _PlayerPageState extends State { final i = pl.state.value.index; if (i != _subsForIndex && i >= 0 && i < pl.playlist.length) { _subsForIndex = i; - c.externalSubtitlesFor(pl.playlist[i]).then((s) { - if (mounted) setState(() => _externalSubs = s); + c.externalSubtitlesFor(pl.playlist[i]).then((s) async { + if (!mounted) return; + setState(() => _externalSubs = s); + // 영상 안 · 같은 폴더 자막이 없고 jj_mkv 에 만든 자막 (번역 · 받은 자막) 만 있으면 하나를 켠다 (한국어 먼저). + // 같은 폴더 자막은 mpv 가 스스로 켜므로 그 뒤에 (1초) 확인한다. + await Future.delayed(const Duration(seconds: 1)); + final st = pl.state.value; + if (!mounted || s.isEmpty || st.index != i || st.subtitleId != null || st.subtitleTracks.isNotEmpty) return; + final pick = s.firstWhere((f) => RegExp(r'[._-](ko|kor)[._]', caseSensitive: false).hasMatch(p.basename(f)), + orElse: () => s.first); + await _setSub(TrackInfo('file:$pick', p.basename(pick), file: pick)); }); } } @@ -179,17 +188,29 @@ class _PlayerPageState extends State { void _toggleSubtitles() { final s = pl.state.value; if (s.subtitleId != null) { - pl.setSubtitleTrack(null); + _setSub(null); } else { final all = _subtitleChoices(); - if (all.isNotEmpty) pl.setSubtitleTrack(all.first); + if (all.isNotEmpty) _setSub(all.first); } } - List _subtitleChoices() => [ - ...pl.state.value.subtitleTracks, - for (final f in _externalSubs) TrackInfo('file:$f', p.basename(f), file: f), - ]; + /// 자막 고르기. 파일 자막은 플레이어가 읽을 수 있는 형태로 (Android: UTF-8 사본) 넘긴다 - 고른 표시는 원래 id 로. + Future _setSub(TrackInfo? t) async { + final f = t?.file; + if (t != null && f != null) t = TrackInfo(t.id, t.label, file: await c.playableSubtitle(f)); + await pl.setSubtitleTrack(t); + } + + List _subtitleChoices() { + final tracks = pl.state.value.subtitleTracks; + return [ + ...tracks, + // mpv 가 같은 폴더에서 스스로 불러온 파일은 다시 넣지 않는다 (목록에 두 번 보이지 않게) + for (final f in _externalSubs) + if (!tracks.any((t) => t.label.contains(p.basename(f)))) TrackInfo('file:$f', p.basename(f), file: f), + ]; + } Future _addDropped(List paths) async { final videos = await collectVideos(paths, @@ -330,15 +351,18 @@ class _PlayerPageState extends State { item('다음', () => pl.next(), key: 'N'), const Divider(height: 1), item('전체 화면', _toggleFull, key: 'F', checked: _full), - item('팝업 보기', _togglePopup, checked: _popup), - SubmenuButton(menuChildren: [ - for (final f in const [0.5, 1.0, 2.0, 4.0]) item('원본의 $f배', () => _fit(f)), - ], child: const Text('화면 크기')), + // 팝업 (항상 위 작은 창) · 창 크기는 PC 만 + if (!Platform.isAndroid) ...[ + item('팝업 보기', _togglePopup, checked: _popup), + SubmenuButton(menuChildren: [ + for (final f in const [0.5, 1.0, 2.0, 4.0]) item('원본의 $f배', () => _fit(f)), + ], child: const Text('화면 크기')), + ], const Divider(height: 1), item('자막 보기', _toggleSubtitles, key: 'S', checked: s.subtitleId != null), SubmenuButton(menuChildren: [ - item('끄기', () => pl.setSubtitleTrack(null), checked: s.subtitleId == null), - for (final t in subs) item(t.label, () => pl.setSubtitleTrack(t), checked: s.subtitleId == t.id), + item('끄기', () => _setSub(null), checked: s.subtitleId == null), + for (final t in subs) item(t.label, () => _setSub(t), checked: s.subtitleId == t.id), ], child: const Text('자막 선택')), if (s.audioTracks.length > 1) SubmenuButton(menuChildren: [ @@ -414,9 +438,9 @@ class _PlayerPageState extends State { (label: '${r}x', checked: s.rate == r, onTap: () => pl.setRate(r)), ]), _menuButton(Icons.subtitles, '자막 (S)', [ - (label: '끄기', checked: s.subtitleId == null, onTap: () => pl.setSubtitleTrack(null)), + (label: '끄기', checked: s.subtitleId == null, onTap: () => _setSub(null)), for (final t in _subtitleChoices()) - (label: t.label, checked: s.subtitleId == t.id, onTap: () => pl.setSubtitleTrack(t)), + (label: t.label, checked: s.subtitleId == t.id, onTap: () => _setSub(t)), ]), if (s.audioTracks.length > 1) _menuButton(Icons.audiotrack, '음성', [ @@ -425,8 +449,9 @@ class _PlayerPageState extends State { ]), btn(Icons.playlist_play, '재생 목록 (L)', () => setState(() => _showList = !_showList)), ], - btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? '팝업 끄기' : '팝업 보기', - _togglePopup), + if (!Platform.isAndroid) + btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? '팝업 끄기' : '팝업 보기', + _togglePopup), btn(_full ? Icons.fullscreen_exit : Icons.fullscreen, '전체 화면 (F)', _toggleFull), ]), ]), @@ -479,6 +504,7 @@ class _PlayerPageState extends State { ), ), ), + if (!Platform.isAndroid) const Padding( padding: EdgeInsets.all(8), child: Text('동영상을 끌어다 놓으면 목록에 추가됩니다', diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 2723320..ee7b339 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -435,7 +435,7 @@ class _SettingsPageState extends State { (v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())), _textTile('아이디 (선택)', s.openSubtitlesUser, '로그인하면 하루 받기 횟수가 늘어납니다', (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), - _textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다', + _textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 ${desk ? 'PC' : '기기'} 의 설정 파일에 저장됩니다', (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), ListTile( title: const Text('YouTube 받을 형식'), diff --git a/lib/ui/translate_dialog.dart b/lib/ui/translate_dialog.dart index 3b09cf4..46b54fc 100644 --- a/lib/ui/translate_dialog.dart +++ b/lib/ui/translate_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import '../app/app_controller.dart'; @@ -84,7 +86,7 @@ class _TranslateDialogState extends State<_TranslateDialog> { ]), const SizedBox(height: 16), Row(children: [ - const Text('번역 모델: NLLB-200 (이 PC 에서만) ', style: TextStyle(fontSize: 13)), + Text('번역 모델: NLLB-200 (이 ${Platform.isAndroid ? '기기' : 'PC'} 에서만) ', style: const TextStyle(fontSize: 13)), if (_installed != null) Text(_installed! ? '설치됨' : '처음 사용 시 내려받기 ${nllbModel.sizeLabel}', style: TextStyle(fontSize: 11, color: _installed! ? JjColors.success : JjColors.textDim)), diff --git a/test/core_test.dart b/test/core_test.dart index 9795c07..b9cdf5f 100644 --- a/test/core_test.dart +++ b/test/core_test.dart @@ -112,6 +112,10 @@ At least one output file must be specified // 문자셋은 해당 입력 앞에만 expect(a, contains('-sub_charenc CP949 -i a.ko.smi -i a.ja.srt')); + // 앱이 UTF-8 로 바꿔 둔 사본을 쓰면 문자셋 지정 없이 사본을 읽는다 (Android 의 FFmpeg 는 iconv 가 없음) + final copy = buildMuxArgs(v, 'out.mkv', utf8Copies: {v.subtitles[3]: 'tmp/utf8_a.ko.smi'}).join(' '); + expect(copy, contains('-i in.mkv -i tmp/utf8_a.ko.smi -i a.ja.srt')); + expect(copy, isNot(contains('-sub_charenc'))); // 삭제된 트랙(4)은 매핑하지 않음 expect(a, contains('-map 0:2 -map 0:3 -map 1:0 -map 2:0')); expect(a, isNot(contains('0:4')));