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); /// 자막 파일을 골라 [v] 에 더한다 (이미 있는 경로는 건너뜀, 언어는 파일 이름으로). 더한 수 Future pickFor(VideoItem v) async { var n = 0; for (final path in await storage.pickSubtitles(initialDirectory: v.directory)) { if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, path))) continue; final d = findSiblingSubtitles(v.path, [path]); v.subtitles.add(await external(path, d.isEmpty ? undetermined : d.first.language)); n++; } return n; } /// 편집할 수 있는 자막인지 (이미지 자막은 아님 · 내장 자막은 FFmpeg 가 있어야 꺼냄) static bool canEdit(SubtitleEntry s, {required bool hasFfmpeg}) => !(s.kind == SubtitleKind.embedded && bitmapSubtitleCodecs.contains(s.codec)) && (s.kind == SubtitleKind.external || hasFfmpeg); /// [cues] 를 SRT 로 다른 이름 저장 (목록은 바꾸지 않음). (저장한 경로 - 취소면 null, 표현할 수 없어 '?' 로 바뀐 글자 수) Future<(String?, int)> saveAs(VideoItem v, List cues, String charset, String fileName) async { final enc = encodeText(formatSrt(cues), charset); final path = await storage.saveAs(fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory); return (path, enc.lostChars); } /// 외부 자막 항목 (문자셋은 앞 64KB 로 알아낸다, 못 읽으면 UTF-8) Future 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> 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 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 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 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> externalFor(String video) async { final out = []; 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; } }