JJ_MKVMaker/lib/ui/explorer/open_flow.dart

371 lines
16 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/component_store.dart';
import '../../app/file_opener.dart';
import '../../core/file_ops.dart';
import '../../core/hwp_source.dart';
import '../../core/playlist.dart' show folderPlaylist, isAudioFile, isVideoFile;
import '../../core/reader_sources.dart';
import '../../core/vfs.dart';
import '../file_error.dart' show explainFileError;
import '../player_page.dart' show playFiles;
import '../../l10n/tr.dart';
import '../../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import '../archive_ui.dart';
import '../dav_external.dart';
import '../explorer_grid.dart';
import '../progress_dialog.dart';
import '../quick_look.dart';
import '../reader_page.dart';
import '../text_viewer.dart';
import '../zip_page.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'file_thumbs.dart';
import 'name_dialog.dart';
import 'write_guard.dart';
/// 약속 (E5d-5): 파일을 여는 일. 여는 길은 file_opener ([openRouteFor]) 가 고르고, 여기는 그 길대로 실제로 연다 -
/// 내장 플레이어 · 그림 보기 (같은 폴더를 지금 순서로) · PDF · HWP (Android, 못 열면 다른 앱) · 문서 미리 보기 (PDF 로 바꿔) ·
/// ZIP (만화 보기 · 목록) · 압축 풀기 · 다른 앱. 압축 안의 파일은 꺼내서 ([ExplorerGuard.extractOne]), WebDAV 등 원격은 받아서 ([fetch]).
/// 열 앱이 없으면 글 파일은 글 뷰어로, 아니면 까닭을 알린다 (Host). [otherDirOf]: 압축을 풀 다른 창의 폴더 (없으면 null)
class ExplorerOpener {
ExplorerOpener(this.ctl, this.sel, this.guard, {required this.otherDirOf});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerGuard guard;
final String? Function(ExplorerPane pane) otherDirOf;
/// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29)
static String noAppMessage(String path) => isAudioFile(path) || isVideoFile(path)
? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.')
: tr('이 파일을 열 수 있는 앱이 없습니다.');
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> open(BuildContext context, String path, {ExplorerPane? pane}) async {
// X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다
if (guard.insideArchive(path)) {
final local = await guard.extractOne(path);
if (local == null || !context.mounted) return;
path = local;
}
// 여는 길은 file_opener (E5c) 가 고른다
switch (openRouteFor(path,
components: ctl.c.settings.display.components,
imageExts: ctl.c.settings.reader.imageExts,
android: Platform.isAndroid,
windows: Platform.isWindows,
hwpEngine: RhwpEngine.available)) {
case OpenRoute.player:
return play(context, [path]);
case OpenRoute.extractArchive:
return extractArchive(context, path, pane ?? ctl.pane);
case OpenRoute.images:
return viewImages(context, path, pane);
case OpenRoute.pdf:
return viewPdf(context, path);
case OpenRoute.zip:
return openZipFile(context, path, pane);
// 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로
case OpenRoute.hwp:
if (await viewHwp(context, path)) return;
case OpenRoute.docPreview:
return viewDoc(context, path);
case OpenRoute.external:
}
if (!context.mounted) return;
final local = await fetch(context, path);
if (local == null) return;
final ok = await ctl.c.services.shell.openWith(local);
if (ok || !context.mounted) return;
// X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로
if (textExtensions.contains(extOf(path))) return viewText(context, local);
ctl.host.snack(noAppMessage(path));
}
/// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤)
Future<void> viewText(BuildContext context, String path) async {
final local = await fetch(context, path);
if (local == null || !context.mounted) return;
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: local)));
}
/// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로
Future<void> grid(BuildContext context, ExplorerPane pane, String dir) async {
await ctl.load(pane, dir);
if (!context.mounted) return;
final list = sel.shown(pane, dir);
final at = await Navigator.of(context).push<String>(MaterialPageRoute(
builder: (_) => ExplorerGridPage(
title: vBasename(dir).isEmpty ? dir : vBasename(dir),
entries: list,
imageExts: ctl.c.settings.reader.imageExts,
videoThumb: (e) => BigVideoThumbs.of(ctl.c, e),
onOpen: (e) => open(context, e.path, pane: pane),
),
));
if (at == null || !context.mounted) return;
await ctl.goTo(pane, dir);
pane.focused = at;
pane.changed();
}
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart)
Future<String?> fetch(BuildContext context, String path) => fetchDav(context, ctl.c, path);
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
Future<void> play(BuildContext context, List<String> paths, {bool internal = false, bool keepOrder = false}) async {
if (!context.mounted || paths.isEmpty) return;
await playFiles(context, ctl.c, paths, internal: internal, keepOrder: keepOrder);
}
/// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다
Future<void> viewImages(BuildContext context, String path, ExplorerPane? pane) async {
final dir = vDirname(path);
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
List<FileEntry> list;
try {
list = pane?.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorer.showHidden), dir);
} catch (_) {
list = const [];
}
final images = [
for (final e in list)
if (!e.isDir && ctl.c.settings.reader.imageExts.contains(extOf(e.path))) e.path,
];
if (!images.any((x) => samePath(x, path))) images.insert(0, path);
final start = images.indexWhere((x) => samePath(x, path));
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return;
await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작
Future<void> slideshow(BuildContext context, ExplorerPane pane, String dir) async {
List<FileEntry> list;
try {
list = pane.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorer.showHidden), dir);
} catch (e) {
ctl.host.snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
return;
}
final images = [
for (final e in list)
if (!e.isDir && ctl.c.settings.reader.imageExts.contains(extOf(e.path)) && (ctl.showAppHidden || !ctl.appHidden(e.path))) e.path,
];
if (images.isEmpty) {
ctl.host.snack(tr('이 폴더에는 그림이 없습니다'));
return;
}
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return;
await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true);
}
/// 문서 → PDF (진행 창) → 보기
Future<void> viewDoc(BuildContext context, String path) async {
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return;
final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
String? pdf;
Object? error;
try {
final local = await vLocalCopy(path, temp);
pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs'));
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
if (dialog.cancelled()) return;
dialog.close();
await dialog.done;
if (pdf == null) {
ctl.host.snack(trf('미리 볼 수 없습니다: {0}', [error]));
return;
}
PdfSource src;
try {
src = await PdfSource.open(pdf, tempDir: temp);
} catch (e) {
ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
return;
}
if (!context.mounted) return;
await openReader(context, ctl.c, src);
}
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
Future<bool> viewHwp(BuildContext context, String path) async {
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return true;
final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)]));
HwpSource? src;
Object? error;
try {
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: ctl.c.note);
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
if (dialog.cancelled()) {
await src?.dispose();
return true;
}
dialog.close();
await dialog.done;
if (src == null) {
ctl.host.snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
return false;
}
if (!context.mounted) {
await src.dispose();
return true;
}
await openReader(context, ctl.c, src);
return true;
}
Future<void> viewPdf(BuildContext context, String path) async {
final temp = await ctl.c.services.storage.tempDirectory();
PdfSource src;
try {
src = await PdfSource.open(path, tempDir: temp);
} catch (e) {
ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
return;
}
if (!context.mounted) return;
await openReader(context, ctl.c, src);
await ctl.refreshAll([vDirname(path)]);
}
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
Future<void> extractArchive(BuildContext context, String path, ExplorerPane pane) async {
final other = otherDirOf(pane);
final out = await extractArchiveFlow(context, ctl.c, path, otherDir: other);
if (out != null && context.mounted) await ctl.refreshAll([out, vDirname(out)]);
}
Future<void> openZipFile(BuildContext context, String path, ExplorerPane? pane) async {
try {
if (ctl.c.settings.reader.zipComic && await openZipComic(context, ctl.c, path)) return;
} catch (e) {
ctl.host.snack(trf('열 수 없습니다: {0}', [e]));
return;
}
if (!context.mounted) return;
final other = pane == null ? null : otherDirOf(pane);
final out = await openZip(context, ctl.c, path, otherDir: other);
if (out != null) await ctl.refreshAll([out, vDirname(out)]);
}
/// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다)
Future<void> makePdf(BuildContext context, List<String> images) async {
if (images.isEmpty) return;
final dir = vDirname(images.first);
final first = vBasename(images.first);
final name = await askName(context, tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf');
if (name == null || name.trim().isEmpty) return;
final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf');
final temp = await ctl.c.services.storage.tempDirectory();
try {
await imagesToPdf(images, out, tempDir: temp);
ctl.host.snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)]));
} catch (e) {
ctl.host.snack(trf('PDF 를 만들지 못했습니다: {0}', [e]));
}
await ctl.refreshAll([dir]);
}
Future<void> playFolder(BuildContext context, String dir) async {
final List<String> list;
try {
// 재생 목록 모듈 (82a87da): 동영상이 없는 폴더 (음악 폴더) 는 소리 파일을
list = folderPlaylist([for (final e in await listEntries(dir)) if (!e.isDir) e.path]);
} catch (e) {
ctl.host.snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1]));
return;
}
if (list.isEmpty) {
ctl.host.snack(tr('이 폴더에 재생할 동영상 · 소리 파일이 없습니다.'));
return;
}
if (!context.mounted) return;
await play(context, list);
}
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
Future<void> share(BuildContext context, List<String> paths) async {
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
if (files.isEmpty) {
ctl.host.snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
return;
}
final local = <String>[];
for (final f in files) {
final l = await fetch(context, f);
if (l == null || !context.mounted) return;
local.add(l);
}
if (!await ctl.c.services.shell.shareFiles(local)) {
ctl.host.snack(tr('공유 창을 열 수 없습니다.'));
} else if (files.length < paths.length) {
ctl.host.snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length]));
}
}
/// 동영상 (폴더면 그 안의 것) 을 MKV 목록에 - 원격은 넣지 않고 알림, 읽지 못한 폴더는 건너뛰고 알림
Future<void> addMkv(List<String> paths) async {
if (paths.any(isDav)) {
ctl.host.snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
return;
}
final videos = <String>[];
final unreadable = <String>[];
for (final x in paths) {
if (vIsDirSync(x)) {
try {
videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
} catch (_) {
unreadable.add(vBasename(x)); // 읽지 못한 폴더는 건너뛰고 알린다
}
} else if (isVideoFile(x)) {
videos.add(x);
}
}
if (unreadable.isNotEmpty) ctl.host.snack(trf('읽을 수 없어 건너뛴 폴더: {0}', [unreadable.take(3).join(', ')]));
if (videos.isEmpty) {
if (unreadable.isEmpty) ctl.host.snack(tr('추가할 동영상이 없습니다.'));
return;
}
await ctl.c.addVideos(videos, allowOutputFolder: true);
ctl.host.snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length]));
}
/// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
Future<void> quickLook(BuildContext context, ExplorerPane pane, [String? path]) async {
final f = path ?? pane.focused;
if (f == null || samePath(f, pane.root)) return;
final list = sel.shown(pane, vDirname(f));
final i = list.indexWhere((x) => samePath(x.path, f));
if (i < 0) return;
await showDialog<void>(
context: context,
builder: (_) => QuickLook(
entries: list,
index: i,
probe: ctl.c.services.mediaTool.probe,
thumbnail: (e) => BigVideoThumbs.of(ctl.c, e),
imageExts: ctl.c.settings.reader.imageExts,
onOpen: (e) => e.isDir ? unawaited(ctl.goTo(pane, e.path)) : unawaited(open(context, e.path, pane: pane)),
),
);
}
}