370 lines
16 KiB
Dart
370 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 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 {
|
|
list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList();
|
|
} 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)),
|
|
),
|
|
);
|
|
}
|
|
}
|