Android 전체 기능 점검에서 찾은 문제 수정
- CP949 등 UTF-8 이 아닌 자막: MKV 만들기 · 자막 편집기 열기가 실패하던 것 (Android 의 FFmpeg 에 iconv 없음) → 앱이 UTF-8 사본을 만들어 넘김 (모든 플랫폼 같은 방식) - 플레이어: CP949 SMI 자막이 보이지 않던 것 → UTF-8 SRT 사본으로 (Android). 'auto' 를 켜진 자막으로 여겨 jj_mkv 자막 (번역 · 받은 자막) 을 자동으로 켜지 않던 것. 같은 자막이 메뉴에 두 번 보이던 것 - 자막 편집기 미리보기: 같은 폴더 자막을 열지 못하면 "영상 재생 오류" 가 뜨던 것 - MKV 만들기 실패 시 0 바이트 MKV 가 남던 것 (시작 전 실패는 전에 만든 MKV 를 그대로 둠) - AV1 영상 재인코딩 (Android FFmpeg 에 AV1 디코더 없음): "ffmpeg 오류 (코드 69)" 대신 원본 유지 안내 - 임시 파일: 캐시 폴더 → 앱 데이터 폴더 (저장 공간이 모자라면 Android 가 쓰는 중인 임시 파일을 지움) - [폴더 열기] · 다운로드 폴더 · 외부 브라우저로 열기: Android 에서 아무 동작도 안 하던 것 → 파일 앱 · 기본 브라우저 - 플레이어의 팝업 · 창 크기 · 끌어다 놓기 안내 숨김 (PC 전용), "이 PC" → "이 기기" - 취소한 다운로드에 속도 · 남은 시간이 남던 것 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
42bf9f990b
commit
4592dc7943
|
|
@ -27,6 +27,8 @@ class MainActivity : FlutterActivity() {
|
|||
result.success(null)
|
||||
}
|
||||
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
|
||||
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
|
||||
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||
"keepAlive" -> {
|
||||
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String?> _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<String> 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<List<String>> externalSubtitlesFor(String video) async {
|
||||
final out = <String>[];
|
||||
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 = <SubtitleEntry, String>{};
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ const preferredDefaultLanguage = 'kor';
|
|||
/// 동영상 + 자막 → MKV 로 합치는 FFmpeg 인수.
|
||||
///
|
||||
/// [encode] 가 재인코딩이면 영상은 선택한 코덱·크기로 인코딩하고, 음성은 항상 그대로 복사한다.
|
||||
///
|
||||
/// [utf8Copies]: UTF-8 로 바꿔 둔 외부 자막 사본 (원래 항목 → 사본 경로). 사본은 문자셋 지정 없이 읽는다.
|
||||
List<String> buildMuxArgs(
|
||||
VideoItem video,
|
||||
String outputPath, {
|
||||
EncodeSettings encode = const EncodeSettings(),
|
||||
Set<String> encoders = const {},
|
||||
Map<SubtitleEntry, String> utf8Copies = const {},
|
||||
}) {
|
||||
final externals = video.subtitles
|
||||
.where((s) => s.enabled && s.kind == SubtitleKind.external)
|
||||
|
|
@ -39,12 +42,13 @@ List<String> 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!]);
|
||||
}
|
||||
|
||||
// 영상(표지 그림 제외)·음성 트랙 전부, 첨부 파일(폰트) 유지
|
||||
|
|
|
|||
|
|
@ -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<bool> isFullScreen() async => _full;
|
||||
|
||||
/// 기기의 기본 브라우저로 (고른 브라우저는 PC 에서만)
|
||||
@override
|
||||
Future<void> openUrl(String url, {String browser = 'system'}) async {
|
||||
try {
|
||||
await _ch.invokeMethod<bool>('openUrl', {'url': url});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 파일이 있는 폴더 (폴더면 그 폴더) 를 파일 앱으로
|
||||
@override
|
||||
Future<void> revealFile(String path) async {
|
||||
final dir = await FileSystemEntity.isDirectory(path) ? path : p.dirname(path);
|
||||
try {
|
||||
await _ch.invokeMethod<bool>('openFolder', {'path': dir});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,10 +58,20 @@ class AndroidStorageService extends DesktopStorageService {
|
|||
Future<List<String>> pickSubtitles({String? initialDirectory}) =>
|
||||
_pick('자막 파일 선택', subtitleExtensions, initialDirectory: initialDirectory);
|
||||
|
||||
/// 임시 폴더: 캐시 폴더가 아니라 앱 데이터 폴더 아래 tmp.
|
||||
/// 저장 공간이 모자라면 Android 가 캐시 폴더를 마음대로 비워, 쓰는 중인 임시 파일 (AI 음성 · 자막 사본) 이
|
||||
/// 사라질 수 있다. 대신 앱을 켤 때 (처음 쓸 때) 지난 실행의 임시 파일을 지운다.
|
||||
static Future<String>? _tmp;
|
||||
|
||||
@override
|
||||
Future<String> tempDirectory() async {
|
||||
final dir = Directory(p.join((await getTemporaryDirectory()).path, 'jj_mkvmaker'));
|
||||
Future<String> 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;
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>.delayed(const Duration(milliseconds: 300)); // 파일 핸들 해제 대기
|
||||
for (final f in t.files) {
|
||||
for (final path in [f, '$f.aria2']) {
|
||||
|
|
|
|||
|
|
@ -272,7 +272,10 @@ class YtDlpBackend implements DownloadBackend {
|
|||
@override
|
||||
Future<void> 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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -92,8 +92,17 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||
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<void>.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<PlayerPage> {
|
|||
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<TrackInfo> _subtitleChoices() => [
|
||||
...pl.state.value.subtitleTracks,
|
||||
for (final f in _externalSubs) TrackInfo('file:$f', p.basename(f), file: f),
|
||||
/// 자막 고르기. 파일 자막은 플레이어가 읽을 수 있는 형태로 (Android: UTF-8 사본) 넘긴다 - 고른 표시는 원래 id 로.
|
||||
Future<void> _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<TrackInfo> _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<void> _addDropped(List<String> paths) async {
|
||||
final videos = await collectVideos(paths,
|
||||
|
|
@ -330,15 +351,18 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||
item('다음', () => pl.next(), key: 'N'),
|
||||
const Divider(height: 1),
|
||||
item('전체 화면', _toggleFull, key: 'F', checked: _full),
|
||||
// 팝업 (항상 위 작은 창) · 창 크기는 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<PlayerPage> {
|
|||
(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,6 +449,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||
]),
|
||||
btn(Icons.playlist_play, '재생 목록 (L)', () => setState(() => _showList = !_showList)),
|
||||
],
|
||||
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<PlayerPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (!Platform.isAndroid)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Text('동영상을 끌어다 놓으면 목록에 추가됩니다',
|
||||
|
|
|
|||
|
|
@ -435,7 +435,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
(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 받을 형식'),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
|
|
|
|||
Loading…
Reference in New Issue