JJ_MKVMaker/lib/app/controller/subtitle_files.dart

170 lines
7.7 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../../core/charset_detector.dart';
import '../../core/languages.dart';
import '../../core/mkv_command_builder.dart';
import '../../core/models.dart';
import '../../core/output_paths.dart';
import '../../core/srt.dart';
import '../../core/subtitle_detector.dart';
import '../../core/text_codec.dart';
import '../../core/vfs.dart';
import '../../services/media_tool.dart';
import '../../services/storage_service.dart';
/// 자막 파일 다루기 (읽기 · UTF-8 사본 · 편집본 이름 · 재생용 사본 · 재생 중 고를 외부 자막).
/// 약속: 동영상 목록 · 화면 상태를 바꾸지 않는다 - 파일과 [SubtitleEntry] 만 만들어 준다. 임시 파일은 [storage] 의 임시 폴더에.
class SubtitleFiles {
final StorageService storage;
final MediaTool tool;
SubtitleFiles(this.storage, this.tool);
/// 외부 자막 항목 (문자셋은 앞 64KB 로 알아낸다, 못 읽으면 UTF-8)
Future<SubtitleEntry> external(String path, Language lang) async {
String charset = 'UTF-8';
try {
final head = await storage.readHead(path, 64 * 1024);
charset = detectCharset(Uint8List.fromList(head));
} catch (_) {}
return SubtitleEntry.external(path: path, language: lang, charset: charset);
}
/// 앱이 만든 UTF-8 자막 [path] 를 [v] 의 자막 목록에 넣는다 (같은 파일이 있으면 바꿔 넣음)
static void addOrReplace(VideoItem v, String path, Language lang, String title) {
final entry = SubtitleEntry.external(path: path, language: lang, charset: 'UTF-8', title: '${lang.name} ($title)');
final i = v.subtitles.indexWhere((s) => s.path != null && p.equals(s.path!, path));
if (i >= 0) {
v.subtitles[i] = entry;
} else {
v.subtitles.add(entry);
}
}
/// 자막을 큐 목록으로 불러온다.
/// - 외부 SRT: 지정 문자셋으로 직접 읽음
/// - 외부 SMI/ASS/VTT, 내장 트랙: FFmpeg 로 UTF-8 SRT 변환 후 읽음
Future<List<Cue>> loadCues(VideoItem v, SubtitleEntry s) async {
if (s.kind == SubtitleKind.external && s.codec == 'srt') {
final bytes = await storage.readBytes(s.path!);
return parseSrt(decodeText(bytes, s.charset ?? 'UTF-8'));
}
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: 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 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;
}
/// 편집한 자막을 [editedPath] 에 [charset] 으로 저장하고 [v] 의 목록을 바꾼다 (MKV 에 반영되게).
/// - 내장 자막: 원래 트랙은 '삭제' 표시, 편집본을 외부 자막으로 추가
/// - 외부 자막: 같은 자리의 항목을 편집본으로 교체 (원본 파일은 그대로 둠)
/// 돌려주는 값: (저장 경로, 표현할 수 없어 '?' 로 바뀐 글자 수)
Future<(String, int)> saveEdited(VideoItem v, SubtitleEntry s, List<Cue> cues, String charset) async {
final enc = encodeText(formatSrt(cues), charset);
final target = editedPath(v, s);
await storage.writeBytes(target, enc.bytes);
final edited = SubtitleEntry.external(
path: target,
language: s.language,
charset: charset == 'UTF-8-BOM' ? 'UTF-8' : charset,
title: s.title,
);
final i = v.subtitles.indexOf(s);
if (s.kind == SubtitleKind.embedded) {
s.enabled = false;
v.subtitles.insert(i + 1, edited);
} else {
v.subtitles[i] = edited;
}
if (v.status == JobStatus.done) v.status = JobStatus.ready;
return (target, enc.lostChars);
}
/// 편집본을 저장할 곳: jj_mkv\파일명_언어코드.srt (겹치면 _2 …). 이미 편집본 (jj_mkv 안의 .srt) 이면 같은 파일에 덮어쓴다
static String editedPath(VideoItem v, SubtitleEntry s) {
if (s.kind == SubtitleKind.external &&
p.equals(p.dirname(s.path!), outputDirFor(v.path)) &&
p.extension(s.path!).toLowerCase() == '.srt') {
return s.path!;
}
final base = languageSubtitlePath(v.path, s.language);
final used = v.subtitles.where((e) => e != s && e.path != null).map((e) => p.normalize(e.path!).toLowerCase()).toSet();
var candidate = base;
for (var n = 2; used.contains(p.normalize(candidate).toLowerCase()); n++) {
candidate = '${p.withoutExtension(base)}_$n.srt';
}
return candidate;
}
/// 플레이어에 넘길 자막 파일 (Android). Android 의 mpv 는 문자셋 변환 (iconv) 이 없고 SAMI (SMI) 도 열지 못하므로
/// UTF-8 이 아닌 자막은 UTF-8 사본으로, SMI 는 SRT 로 바꿔 임시 폴더에 만든다. 그 밖에는 그대로.
Future<String> playable(String path, {bool android = false}) async {
if (!android) return path;
try {
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;
}
}
/// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt
Future<List<String>> externalFor(String video) async {
final out = <String>[];
if (isDav(video)) {
// WebDAV: 같은 폴더의 자막만 (작으므로 임시 폴더로 받아서 넘긴다)
try {
final files = [for (final e in await vList(vDirname(video))) if (!e.isDir) e.path];
final temp = await storage.tempDirectory();
for (final d in findSiblingSubtitles(video, files)) {
out.add(await vLocalCopy(d.path, temp));
}
} catch (_) {}
return out;
}
out.addAll(findSiblingSubtitles(video, await storage.listFiles(p.dirname(video))).map((d) => d.path));
final base = p.basenameWithoutExtension(video).toLowerCase();
for (final f in await storage.listFiles(outputDirFor(video))) {
final n = p.basename(f).toLowerCase();
if (n.startsWith('${base}_') && subtitleExtensions.contains(p.extension(n).replaceFirst('.', ''))) {
out.add(f);
}
}
return out;
}
}