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 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 viewText(BuildContext context, String path) async { final local = await fetch(context, path); if (local == null || !context.mounted) return; await Navigator.of(context).push(MaterialPageRoute(builder: (_) => TextViewerPage(path: local))); } /// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로 Future 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(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 fetch(BuildContext context, String path) => fetchDav(context, ctl.c, path); /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) Future play(BuildContext context, List 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 viewImages(BuildContext context, String path, ExplorerPane? pane) async { final dir = vDirname(path); // 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만 List 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 slideshow(BuildContext context, ExplorerPane pane, String dir) async { List 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 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 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 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 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 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 makePdf(BuildContext context, List 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 playFolder(BuildContext context, String dir) async { final List 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 share(BuildContext context, List paths) async { final files = [for (final x in paths) if (!vIsDirSync(x)) x]; if (files.isEmpty) { ctl.host.snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.')); return; } final local = []; 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 addMkv(List paths) async { if (paths.any(isDav)) { ctl.host.snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); return; } final videos = []; final unreadable = []; 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 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( 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)), ), ); } }