import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/copy_center.dart'; import '../app/live_sync.dart'; import '../app/settings.dart' show CopyTask; import '../app/transfer_job.dart'; import '../core/sync_tools.dart'; import '../core/file_ops.dart'; import '../core/playlist.dart' show isVideoFile; import '../core/vfs.dart'; import '../core/webdav.dart' show DavRegistry; import '../platform/android/android_storage.dart'; import 'app_actions.dart'; import 'explorer_look.dart'; import 'monitor_page.dart'; import 'player_page.dart'; import 'rsync_setup.dart'; import 'theme.dart'; import 'webdav_settings.dart'; import 'reader_page.dart'; import '../app/component_store.dart'; import '../app/components.dart'; import 'zip_page.dart'; import '../core/reader_sources.dart'; import '../l10n/tr.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// /// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더". /// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램), /// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …). /// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로. /// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴). /// /// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다. /// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다. class ExplorerPage extends StatefulWidget { final AppController c; final bool rsync; const ExplorerPage({super.key, required this.c, this.rsync = false}); static const routeName = 'explorer'; static const rsyncRouteName = 'rsync'; static int _open = 0, _openRsync = 0; /// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로) static Future open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false); /// Rsync 화면으로 (이미 열려 있으면 그 화면으로) static Future openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true); static Future _show(NavigatorState nav, AppController c, {required bool rsync}) async { final name = rsync ? rsyncRouteName : routeName; if ((rsync ? _openRsync : _open) > 0) { var found = false; nav.popUntil((r) { if (r.settings.name == name) found = true; return found || r.isFirst; }); if (found) return; } await nav.push(MaterialPageRoute( settings: RouteSettings(name: name), builder: (_) => ExplorerPage(c: c, rsync: rsync), )); } @override State createState() => _ExplorerPageState(); } /// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. enum ExplorerButton { up(Icons.arrow_upward, '상위 폴더'), refresh(Icons.refresh, '새로 고침'), select(Icons.checklist, '선택'), // ── Rsync 화면에만 ── toRight(Icons.arrow_forward, '좌 → 우'), toLeft(Icons.arrow_back, '좌 ← 우'), both(Icons.compare_arrows, '좌 ⇄ 우'), monitor(Icons.monitor_heart_outlined, '모니터링'), // ── play(Icons.play_circle_outline, '재생'), addMkv(Icons.playlist_add, 'MKV 목록에 추가'), newFolder(Icons.create_new_folder_outlined, '새 폴더'), rename(Icons.drive_file_rename_outline, '이름 변경'), copy(Icons.copy_outlined, '복사'), move(Icons.drive_file_move_outline, '이동'), paste(Icons.content_paste, '붙여넣기'), delete(Icons.delete_outline, '삭제'), search(Icons.search, '찾기'), sort(Icons.sort, '정렬 기준'), hidden(Icons.visibility_outlined, '숨은 항목 표시'), history(Icons.history, '내역'), layout(Icons.view_quilt_outlined, '창 배치'), buttons(Icons.tune, '버튼 구성'); final IconData icon; final String label; const ExplorerButton(this.icon, this.label); /// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기) bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; /// 파일 탐색기에서 고를 수 있는 버튼 static List get explorerValues => [for (final b in values) if (!b.rsyncOnly) b]; /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) static const rsyncButtons = [up, refresh, newFolder, toRight, toLeft, both, monitor]; static List fromSettings(List names) { final out = [ for (final n in names) for (final b in explorerValues) if (b.name == n) b, ]; return out.isEmpty ? explorerValues : out; } } /// 한 창의 상태: 저장 장치 (맨 위), 펼친 폴더, 읽어 둔 목록, 표시한 항목 class _Pane extends ChangeNotifier { String root; String current; String? focused; final expanded = {}; final cache = >{}; final loading = {}; final errors = {}; final marked = {}; /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) final scroll = ScrollController(); /// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤 final listScroll = ScrollController(); _Pane(this.root) : current = root; /// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게 bool _disposed = false; void changed() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; scroll.dispose(); listScroll.dispose(); super.dispose(); } } /// 트리 · 목록의 한 줄 class _Row { final FileEntry entry; final int depth; final bool isRoot; /// 파일 목록 맨 위의 ".." (상위 폴더로) final bool isUp; const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false}); } class _ExplorerPageState extends State { AppController get c => widget.c; /// 이 기기의 저장 장치: (경로, 이름) List<(String, String)> _local = []; /// 저장 장치 + WebDAV 서버 (환경 설정 > 파일 탐색기 > WebDAV): (경로, 이름) List<(String, String)> get _volumes => [ ..._local, for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label), ]; late final List<_Pane> _panes = [_Pane(''), _Pane('')]; int _active = 0; bool _ready = false; @override void initState() { super.initState(); if (widget.rsync) { ExplorerPage._openRsync++; } else { ExplorerPage._open++; } for (final pane in _panes) { pane.addListener(_onPane); } _init(); } @override void dispose() { if (widget.rsync) { ExplorerPage._openRsync--; } else { ExplorerPage._open--; } for (final pane in _panes) { pane.dispose(); } super.dispose(); } void _onPane() { if (mounted) setState(() {}); } Future _init() async { _local = await _loadVolumes(); if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; for (var i = 0; i < 2; i++) { final want = i < saved.length && _reachable(saved[i]) ? saved[i] : _local[0].$1; await _goTo(_panes[i], want, remember: false); } if (!mounted) return; setState(() => _ready = true); for (final pane in _panes) { _reveal(pane, pane.current); // 목록이 그려진 뒤에 } } Future> _loadVolumes() async { if (Platform.isWindows) return windowsDrives(); if (Platform.isAndroid) { final v = await AndroidAccess.volumes(); return [for (final x in v) (x.$1, x.$2.isEmpty ? p.basename(x.$1) : x.$2)]; } return [('/', '/')]; } /// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로 void _dropRemovedServers() { if (!_ready) return; for (final pane in _panes) { if (isDav(pane.root) && DavRegistry.server(DavPath.parse(pane.root).server) == null) { pane.marked.clear(); pane.cache.removeWhere((k, _) => isDav(k)); pane.expanded.removeWhere(isDav); pane.root = _local.first.$1; // 다시 그릴 때 또 하지 않게 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _goTo(pane, _local.first.$1); }); } } } /// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인) bool _reachable(String path) => isDav(path) ? DavRegistry.server(DavPath.parse(path).server) != null : Directory(path).existsSync(); String _volumeOf(String path) { if (isDav(path)) return '$davScheme${DavPath.parse(path).server}/'; String best = _local.first.$1; var len = -1; for (final (v, _) in _local) { final inside = samePath(v, path) || p.isWithin(v, path) || (Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase())); if (inside && v.length > len) { best = v; len = v.length; } } return best; } // ───────── 목록 읽기 · 이동 ───────── Future _load(_Pane pane, String dir, {bool force = false}) async { if (!force && pane.cache.containsKey(dir)) return; pane.loading.add(dir); pane.changed(); try { final list = await listEntries(dir, showHidden: c.settings.explorerShowHidden); pane.cache[dir] = _sorted(list); pane.errors.remove(dir); } catch (e) { pane.cache[dir] = const []; pane.errors[dir] = '$e'; } finally { pane.loading.remove(dir); pane.changed(); } } List _sorted(List list) { final by = SortBy.values.firstWhere((s) => s.name == c.settings.explorerSort, orElse: () => SortBy.name); return sortEntries(list, by, descending: c.settings.explorerSortDesc); } /// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다 Future _goTo(_Pane pane, String dir, {bool remember = true}) async { pane.root = _volumeOf(dir); final chain = []; dir = isDav(dir) ? vNorm(dir) : p.normalize(dir); var d = dir; while (true) { chain.insert(0, d); if (samePath(d, pane.root) || vDirname(d) == d) break; d = vDirname(d); } for (final x in chain) { pane.expanded.add(x); await _load(pane, x); } pane ..current = dir ..focused = dir; pane.changed(); if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); _reveal(pane, dir); if (remember) _remember(pane, dir); } /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) void _reveal(_Pane pane, String path) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !pane.scroll.hasClients) return; // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => samePath(r.entry.path, path)); if (i < 0) return; final pos = pane.scroll.position; final want = (i * _look.rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent); pane.scroll.jumpTo(want); }); } /// 마지막 폴더 · 내역 저장 void _remember(_Pane pane, String dir) { final i = _panes.indexOf(pane); final paths = [..._panes.map((x) => x.current)]; if (widget.rsync) { unawaited(c.updateSettings((s) => s.rsyncPaths = paths)); } else { final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList(); unawaited(c.updateSettings((s) => s ..explorerPaths = paths ..explorerHistory = hist)); } if (i >= 0) _active = i; } /// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일) ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle); /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 bool get _split => !widget.rsync && c.settings.explorerLayout == 'split'; /// 좌우 두 창 (Rsync 화면은 늘) bool get _dual => widget.rsync || c.settings.explorerLayout == 'dual'; /// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기) bool get _selectMode => c.settings.explorerClick != 'open'; /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) { final out = <_Row>[]; final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0)); out.add(_Row(rootEntry, 0, isRoot: true)); void walk(String dir, int depth) { if (!pane.expanded.contains(dir)) return; for (final e in pane.cache[dir] ?? const []) { if (foldersOnly && !e.isDir) continue; out.add(_Row(e, depth)); if (e.isDir) walk(e.path, depth + 1); } } walk(pane.root, 1); return out; } // ───────── 누르기 ───────── /// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기 bool _selecting = false; void _setSelecting(bool on) => setState(() { _selecting = on; if (!on) { for (final x in _panes) { x.marked.clear(); } } }); /// 한 번 누르기. /// - 선택 모드: 고르기 · 풀기 ([_toggleMark]) /// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록) /// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행 /// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로. Future _onTap(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); if (row.isUp) { await _goTo(pane, e.path); return; } if (widget.rsync) { await _pick(pane, e); return; } if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) { if (!_selecting) setState(() => _selecting = true); await _toggleMark(pane, e); return; } if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list); pane.focused = e.path; if (!list) pane.current = vDirname(e.path); pane.changed(); } /// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것). /// Rsync 화면은 고른 그 폴더만. bool _isMarked(_Pane pane, String path) => pane.marked.any((m) => widget.rsync ? samePath(path, m) : isSameOrInside(path, m)); /// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소). 고르면 펼쳐서 안의 폴더가 보이게. Future _pick(_Pane pane, FileEntry e) async { if (!e.isDir) return; if (pane.marked.length == 1 && samePath(pane.marked.first, e.path)) { setState(pane.marked.clear); return; } setState(() => pane.marked ..clear() ..add(e.path)); if (!pane.expanded.contains(e.path)) { await _toggle(pane, e); } else { pane ..current = e.path ..focused = e.path ..changed(); } } /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) void _normalizeMarks(_Pane pane) { final all = pane.marked.toList(); pane.marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o))); } /// 선택 모드에서 누르기. 폴더는 자기 자신과 안의 파일 · 폴더 전체가 한 묶음이다. /// - 고르지 않은 것을 누르면 고른다 (폴더는 펼쳐서 안의 것도 모두 고른 모양으로 보여 줌) /// - 고른 것을 다시 누르면 자기 자신과 안의 것을 모두 푼다. /// 위 폴더를 골라서 함께 골라진 것이면 그것만 빼고, 같은 폴더의 나머지는 고른 채로 둔다. Future _toggleMark(_Pane pane, FileEntry e) async { pane.focused = e.path; if (!_isMarked(pane, e.path)) { setState(() { pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로 pane.marked.add(e.path); }); if (e.isDir) { await _load(pane, e.path); setState(() => pane.expanded.add(e.path)); } return; } final owner = pane.marked.firstWhere((m) => isSameOrInside(e.path, m)); setState(() => pane.marked.removeWhere((m) => isSameOrInside(m, e.path))); if (samePath(owner, e.path)) return; // 위 폴더 [owner] 를 골라 둔 상태: owner 표시를 풀고, e 까지 내려가는 길의 다른 항목은 고른 채로 final keep = []; var dir = owner; while (!samePath(dir, e.path)) { await _load(pane, dir); String? next; for (final x in pane.cache[dir] ?? const []) { if (isSameOrInside(e.path, x.path)) { next = x.path; } else { keep.add(x.path); } } if (next == null) break; dir = next; } setState(() { pane.marked.remove(owner); pane.marked.addAll(keep); }); } /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. Future _onOpen(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); pane.focused = e.path; if (row.isUp || (e.isDir && list)) { await _goTo(pane, e.path); return; } if (e.isDir) { await _toggle(pane, e); return; } if (!list) pane.current = vDirname(e.path); pane.changed(); await _open(e.path, pane: pane); } /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) Future _toggle(_Pane pane, FileEntry e) async { setState(() => _active = _panes.indexOf(pane)); if (pane.expanded.contains(e.path)) { pane.expanded.remove(e.path); // 접은 폴더 안이 지금 폴더였으면 접은 폴더로 if (isSameOrInside(pane.current, e.path)) pane.current = e.path; pane.changed(); return; } pane ..expanded.add(e.path) ..current = e.path ..focused = e.path; await _load(pane, e.path); if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); _remember(pane, e.path); } /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 Future _open(String path, {_Pane? pane}) async { if (isVideoFile(path)) { await _play([path]); return; } // 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때) if (c.settings.components.contains('viewer')) { final ext = extOf(path); if (c.settings.imageExts.contains(ext)) return _viewImages(path, pane); if (ext == 'pdf') return _viewPdf(path); if (zipExtensions.contains(ext)) return _openZip(path, pane); } // 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄) if (c.settings.components.contains('docs') && Platform.isWindows && AppComponent.docExtensions.contains(extOf(path))) { return _viewDoc(path); } final local = await _fetch(path); if (local == null) return; final ok = await c.services.shell.openWith(local); if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); } /// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (받는 동안 진행 창 · 취소). 로컬은 그대로. Future _fetch(String path) async { if (!isDav(path)) return path; final temp = await c.services.storage.tempDirectory(); if (!mounted) return null; final progress = ValueNotifier<(int, int)>((0, 0)); var cancelled = false; final dialog = showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('WebDAV 에서 받는 중')), content: ValueListenableBuilder<(int, int)>( valueListenable: progress, builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(vBasename(path)), const SizedBox(height: 10), LinearProgressIndicator(value: v.$2 > 0 ? v.$1 / v.$2 : null), const SizedBox(height: 6), Text('${formatSize(v.$1)}${v.$2 > 0 ? ' / ${formatSize(v.$2)}' : ''}', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ]), ), actions: [ TextButton( onPressed: () { cancelled = true; Navigator.pop(ctx); }, child: Text(tr('취소')), ), ], ), ); try { final local = await vLocalCopy(path, temp, onProgress: (d, t) => progress.value = (d, t)); return cancelled ? null : local; } catch (e) { if (!cancelled) _snack(trf('받지 못했습니다: {0}', [e])); return null; } finally { if (!cancelled && mounted) Navigator.of(context).pop(); await dialog; progress.dispose(); } } /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) Future _play(List paths, {bool internal = false, bool keepOrder = false}) async { if (!mounted || paths.isEmpty) return; await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder); } /// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다 Future _viewImages(String path, _Pane? pane) async { final dir = vDirname(path); final list = pane?.cache[dir] ?? _sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden)); final images = [ for (final e in list) if (!e.isDir && c.settings.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 c.services.storage.tempDirectory(); if (!mounted) return; await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start); } /// 문서 → PDF (진행 창) → 보기 Future _viewDoc(String path) async { final temp = await c.services.storage.tempDirectory(); if (!mounted) return; final dialog = showDialog( context: context, barrierDismissible: false, builder: (_) => AlertDialog( content: Row(children: [ const CircularProgressIndicator(), const SizedBox(width: 16), Expanded(child: Text(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 (mounted) Navigator.of(context).pop(); await dialog; if (pdf == null) { _snack(trf('미리 볼 수 없습니다: {0}', [error])); return; } PdfSource src; try { src = await PdfSource.open(pdf, tempDir: temp); } catch (e) { _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); return; } if (!mounted) return; await openReader(context, c, src); } Future _viewPdf(String path) async { final temp = await c.services.storage.tempDirectory(); PdfSource src; try { src = await PdfSource.open(path, tempDir: temp); } catch (e) { _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); return; } if (!mounted) return; await openReader(context, c, src); await _refreshAll([vDirname(path)]); } /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 Future _openZip(String path, _Pane? pane) async { try { if (c.settings.zipComic && await openZipComic(context, c, path)) return; } catch (e) { _snack(trf('열 수 없습니다: {0}', [e])); return; } if (!mounted) return; final other = pane == null || !_dual ? null : _panes[1 - _panes.indexOf(pane)].current; final out = await openZip(context, c, path, otherDir: other); if (out != null) await _refreshAll([out, vDirname(out)]); } /// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다) Future _imagesToPdf(List images) async { if (images.isEmpty) return; final dir = vDirname(images.first); final first = vBasename(images.first); final name = await _askName(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 c.services.storage.tempDirectory(); try { await imagesToPdf(images, out, tempDir: temp); _snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)])); } catch (e) { _snack(trf('PDF 를 만들지 못했습니다: {0}', [e])); } await _refreshAll([dir]); } Future _menu(_Pane pane, FileEntry e, Offset at) async { setState(() => _active = _panes.indexOf(pane)); pane.focused = e.path; pane.changed(); final video = !e.isDir && isVideoFile(e.path); final dav = isDav(e.path); // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) final viewer = c.settings.components.contains('viewer'); final picked = pane.marked.contains(e.path) ? pane.marked.toList() : [e.path]; final images = [for (final x in picked) if (c.settings.imageExts.contains(extOf(x))) x]; final ext = extOf(e.path); final viewable = viewer && !e.isDir && (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = _dual; final items = <(String, IconData, String)>[ if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), if (viewable) ('view', Icons.visibility_outlined, tr('보기')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), if (viewer && images.isNotEmpty) ('toPdf', Icons.picture_as_pdf_outlined, images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), ('select', Icons.check_circle_outline, tr('선택')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')), // 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기 ('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')), ('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')), if (_clip.isNotEmpty) ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])), ('delete', Icons.delete_outline, tr('삭제')), if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), ('info', Icons.info_outline, tr('정보')), ]; final size = MediaQuery.sizeOf(context); final pick = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), items: [ for (final (id, icon, label) in items) PopupMenuItem( value: id, child: Row(children: [Icon(icon, size: 18), const SizedBox(width: 12), Text(label)]), ), ], ); if (pick == null || !mounted) return; switch (pick) { case 'open': await _goTo(pane, e.path); case 'openOther': final other = _panes[1 - _panes.indexOf(pane)]; await _goTo(other, e.path); setState(() => _active = _panes.indexOf(other)); case 'playFolder': await _playFolder(e.path); case 'playInternal': await _play([e.path], internal: true); case 'openWith': final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); case 'openDefault': final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); case 'addMkv': await _addMkv([e.path]); case 'select': setState(() => _selecting = true); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); case 'rename': await _rename(pane, e.path); case 'copy': await _transfer(pane, [e.path], move: false); case 'move': await _transfer(pane, [e.path], move: true); case 'clipCopy': _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); case 'clipMove': _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); case 'paste': await _paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': await _delete(pane, [e.path]); case 'view': await _open(e.path, pane: pane); case 'toPdf': await _imagesToPdf(images); case 'reveal': await c.services.shell.revealFile(e.path); case 'info': await _info(e); } } // ───────── 기능 ───────── _Pane get _pane => _panes[_active]; _Pane? get _other => _dual ? _panes[1 - _active] : null; /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) List _targets(_Pane pane) { if (pane.marked.isNotEmpty) return pane.marked.toList(); final f = pane.focused; if (f == null || samePath(f, pane.root)) return const []; return [f]; } void _snack(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } Future _refresh(_Pane pane, Iterable dirs) async { for (final d in {...dirs}) { if (pane.cache.containsKey(d)) await _load(pane, d, force: true); } } /// 바뀐 폴더를 두 창 모두 다시 읽는다 Future _refreshAll(Iterable dirs) async { for (final pane in _panes) { await _refresh(pane, dirs); } } Future _button(ExplorerButton b) async { final pane = _pane; switch (b) { case ExplorerButton.up: if (samePath(pane.current, pane.root)) return; final up = vDirname(pane.current); pane.expanded.remove(pane.current); await _goTo(pane, up); case ExplorerButton.select: _setSelecting(!_selecting); case ExplorerButton.monitor: await MonitorPage.open(context, c); case ExplorerButton.toRight: await _runRsync(right: true, left: false); case ExplorerButton.toLeft: await _runRsync(right: false, left: true); case ExplorerButton.both: await _runRsync(right: true, left: true); case ExplorerButton.refresh: pane.cache.clear(); for (final d in pane.expanded.toList()) { await _load(pane, d, force: true); } case ExplorerButton.play: final t = _targets(pane); final videos = t.where((x) => !vIsDirSync(x) && isVideoFile(x)).toList(); if (videos.isNotEmpty) { await _play(videos, keepOrder: pane.marked.isNotEmpty); } else { await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); } case ExplorerButton.addMkv: final t = _targets(pane); await _addMkv(t.isEmpty ? [pane.current] : t); case ExplorerButton.newFolder: final name = await _askName(tr('새 폴더'), ''); if (name == null) return; try { final parent = pane.current; final made = await FileOps.makeFolder(parent, name); // 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤 pane.expanded.add(parent); await _load(pane, parent, force: true); await _refreshAll([parent]); // Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다 if (widget.rsync) { pane.marked ..clear() ..add(made); } pane.focused = made; pane.changed(); _reveal(pane, made); } catch (e) { _snack(trf('폴더를 만들 수 없습니다: {0}', [e])); } case ExplorerButton.rename: final t = _targets(pane); if (t.length != 1) { _snack(tr('이름을 바꿀 항목 하나를 고르세요.')); return; } await _rename(pane, t.first); case ExplorerButton.copy: await _transfer(pane, _targets(pane), move: false); case ExplorerButton.move: await _transfer(pane, _targets(pane), move: true); case ExplorerButton.paste: await _paste(pane, pane.current); case ExplorerButton.delete: await _delete(pane, _targets(pane)); case ExplorerButton.search: await _search(pane); case ExplorerButton.sort: await _sortDialog(); case ExplorerButton.hidden: await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); for (final x in _panes) { x.cache.clear(); for (final d in x.expanded.toList()) { await _load(x, d, force: true); } } _snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); case ExplorerButton.history: await _historyDialog(pane); case ExplorerButton.layout: await _layoutDialog(); case ExplorerButton.buttons: await _buttonsDialog(); } } Future _playFolder(String dir) async { final list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); if (list.isEmpty) { _snack(tr('이 폴더에 동영상이 없습니다.')); return; } if (!mounted) return; await _play(list); } Future _addMkv(List paths) async { if (paths.any(isDav)) { _snack(tr('WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); return; } final videos = []; for (final x in paths) { if (vIsDirSync(x)) { videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path)); } else if (isVideoFile(x)) { videos.add(x); } } if (videos.isEmpty) { _snack(tr('추가할 동영상이 없습니다.')); return; } await c.addVideos(videos, allowOutputFolder: true); _snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length])); } Future _rename(_Pane pane, String path) async { final name = await _askName(tr('이름 변경'), vBasename(path)); if (name == null || name == vBasename(path)) return; try { final now = await FileOps.rename(path, name); await _refreshAll([vDirname(path)]); for (final x in _panes) { if (x.marked.remove(path)) x.marked.add(now); } pane.focused = now; pane.changed(); } catch (e) { _snack(trf('이름을 바꿀 수 없습니다: {0}', [e])); } } Future _delete(_Pane pane, List paths) async { if (paths.isEmpty) { _snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return; } final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('삭제')), // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) content: Text(paths.length == 1 ? '${trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)])}\n${vDirname(paths.first)}' : [ trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]), for (final x in paths.take(8)) '· ${vBasename(x)}', if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), {for (final x in paths) vDirname(x)}.join('\n'), ].join('\n')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), onPressed: () => Navigator.pop(ctx, true), child: Text(tr('삭제')), ), ], ), ); if (ok != true) return; try { await FileOps().delete(paths); } catch (e) { _snack(trf('지우지 못했습니다: {0}', [e])); } for (final x in _panes) { x.marked.removeAll(paths); if (_selecting && x.marked.isEmpty) _selecting = false; x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first); } await _refreshAll(paths.map(p.dirname)); } /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) Future _transfer(_Pane pane, List sources, {required bool move}) async { final other = _other; if (other == null) { _toClipboard(sources, move: move); return; } await _runTransfer(pane, sources, other.current, move: move); } /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) List _clip = []; bool _clipMove = false; void _toClipboard(List sources, {required bool move}) { if (sources.isEmpty) { _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return; } setState(() { _clip = [...sources]; _clipMove = move; }); _snack(trf('{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', [sources.length, move ? tr('이동') : tr('복사')])); } Future _paste(_Pane pane, String dest) async { if (_clip.isEmpty) { _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); return; } final items = _clip, move = _clipMove; final done = await _runTransfer(pane, items, dest, move: move); // 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김) if (done && move && mounted) setState(() => _clip = []); } /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) /// (Rsync 화면의 ⇄ 는 둘이 함께) List _jobs = const []; /// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름) bool _viaDav(Iterable paths) => paths.any(isDav); /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) Future _ensureRsync() async { final s = c.settings; final have = await rsyncExecutable(s); if (have != null) return have; if (!mounted) return null; if (s.rsyncSource == 'custom' || !Platform.isWindows) { _snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); return null; } return installRsyncWithDialog(context); } /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. Future _runTransfer(_Pane pane, List sources, String dest, {required bool move}) async { if (sources.isEmpty) { _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return false; } if (_jobs.any((j) => !j.finished)) { _snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.')); return false; } // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 final problem = transferProblem(sources, dest, move: move); if (problem != null) { _snack(problem); pane.marked.removeWhere((m) => vMissingSync(m)); await _refreshAll([dest, ...sources.map(p.dirname)]); for (final x in _panes) { x.changed(); } return false; } // 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다) final center = CopyCenter.of(c); final task = center.fresh(sources, dest, move: move); final method = CopyMethod.of(task.method); if (!mounted) return false; final go = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(move ? tr('이동') : tr('복사')), content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}' '\n\n${trf('방법: {0}', [tr(method.label)])}' '${method == CopyMethod.builtin ? '' : ' · ${task.options}'}' '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), ], ), ); if (go != true || !mounted) return false; String? rsync; if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) { rsync = await _ensureRsync(); if (rsync == null || !mounted) return false; } final job = await center.start(task, rsyncExe: rsync); if (job == null || !mounted) return false; setState(() => _jobs = [job]); await job.done; final error = job.error; if (!mounted) return false; pane.marked.clear(); if (_selecting) _setSelecting(false); await _refreshAll([dest, ...sources.map(p.dirname)]); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _panes) { if (samePath(x.current, dest)) { x.expanded.add(dest); await _load(x, dest, force: true); } x.changed(); } _snack(error is FileOpCancelled ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) : trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 await Future.delayed(const Duration(milliseconds: 1200)); if (mounted && _jobs.contains(job)) setState(() => _jobs = const []); return error == null; } /// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께). /// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u). /// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로). Future _runRsync({required bool right, required bool left}) async { final l = _panes[0].marked.firstOrNull, r = _panes[1].marked.firstOrNull; if (l == null || r == null) { _snack(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.')); return; } if (isSameOrInside(l, r) || isSameOrInside(r, l)) { _snack(tr('같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.')); return; } if (_jobs.any((j) => !j.finished)) { _snack(tr('앞의 rsync 가 끝난 뒤에 하세요.')); return; } final runs = [if (right) (l, r), if (left) (r, l)]; for (final (src, dst) in runs) { final problem = transferProblem([src], dst, move: false); if (problem != null) { _snack(problem); for (final x in _panes) { x.marked.removeWhere((m) => vMissingSync(m)); } await _refreshAll([vDirname(l), vDirname(r)]); return; } } final both = right && left; final center = CopyCenter.of(c); // 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다 var tasks = [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync')]; String opts(CopyTask t) => both ? withUpdateOption(t.options) : t.options; // 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리 var removeSource = false; var prune = 'keep'; if (!mounted) return; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'), content: SizedBox( width: 560, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final t in tasks) ...[ Text('${vDisplay(t.sources.first)}/', style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${vDisplay(t.dest)}/', style: const TextStyle(fontWeight: FontWeight.w600)), Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}' '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), const SizedBox(height: 10), ], Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (_viaDav([l, r])) Text(tr('WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).'), style: const TextStyle(fontSize: 12, color: JjColors.accent)), if (both) Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)) else ...[ CheckboxListTile( contentPadding: EdgeInsets.zero, value: removeSource, onChanged: (v) => set(() => removeSource = v ?? false), title: Text(tr('원본 파일 지우기 (--remove-source-files)')), subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), ), if (removeSource) ...[ Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 6), PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), ], ], ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(removeSource ? tr('이동') : tr('실행'))), ], )), ); if (go != true || !mounted) return; final dav = _viaDav([l, r]); final exe = dav ? '' : await _ensureRsync(); if (exe == null || !mounted) return; tasks = [ for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource), ]; if (removeSource) { tasks = [for (final t in tasks) t.copyWith(prune: prune)]; for (final t in tasks) { await center.update(t); } } final jobs = []; for (final t in tasks) { final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: dav ? null : exe); if (j != null) jobs.add(j); } if (jobs.isEmpty || !mounted) return; setState(() => _jobs = jobs); await Future.wait(jobs.map((j) => j.done)); if (!mounted) return; // 원본 폴더까지 지웠으면 고른 표시도 뺀다 for (final x in _panes) { x.marked.removeWhere((m) => vMissingSync(m)); } await _refreshAll([l, r, vDirname(l), vDirname(r)]); for (final x in _panes) { x.changed(); } final error = jobs.map((j) => j.error).whereType().firstOrNull; _snack(error is FileOpCancelled ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) : trf('rsync 끝: {0}', [jobs.map((j) => '${vBasename(j.sources.first)} → ${vBasename(j.dest)}').join(' · ')])); await Future.delayed(const Duration(milliseconds: 1200)); if (mounted && identical(_jobs, jobs)) setState(() => _jobs = const []); } /// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) Widget _transferPanel() { final jobs = _jobs; return AnimatedSlide( offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: jobs.isEmpty ? const SizedBox(height: 0) : Material( elevation: 12, color: JjColors.panel, borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 12, 12), child: Column(mainAxisSize: MainAxisSize.min, children: [ for (final (k, job) in jobs.indexed) ...[ if (k > 0) const Divider(height: 18), ListenableBuilder(listenable: job, builder: (context, _) => _jobView(job)), ], ]), ), ), ); } /// 진행 막대 하나 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) Widget _jobView(TransferJob job) { String pct(double v) => '${(v * 100).round()}%'; final cur = job.index < job.total ? job.index : job.total - 1; final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)]; final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)]; final title = job.finished ? (job.error == null ? tr('완료') : tr('멈춤')) : job.move ? tr('옮기는 중') : tr('복사하는 중'); Widget line(String label, double value, String right) => Padding( padding: const EdgeInsets.only(top: 6), child: Row(children: [ SizedBox( width: 300, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), ), const SizedBox(width: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( minHeight: 8, value: job.counting ? null : value, // 빈 곳과 찬 곳이 분명히 다르게 color: JjColors.accent, backgroundColor: JjColors.border, ), ), ), const SizedBox(width: 10), SizedBox( width: 150, child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ); return Column(mainAxisSize: MainAxisSize.min, children: [ Row(children: [ Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent), const SizedBox(width: 8), Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(width: 12), Expanded( // Rsync 화면: 원본 → 대상 (⇄ 는 두 줄) child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))), ]), // Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수) if (widget.rsync) ...[ line( job.counting ? tr('파일 세는 중…') : job.pruning ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) : job.finished ? tr('끝') : job.currentFile.isEmpty ? tr('시작하는 중…') : job.currentFile.split('/').last, job.finished ? 1.0 : (job.filePercent ?? 0), [ if (job.filePercent != null && !job.finished) pct(job.filePercent!), if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s', ].join(' · '), ), line( trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]), job.folderProgress, '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', ), ] else ...[ // 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중 line( trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]), job.overall, '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}' '${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}', ), line( job.counting ? tr('파일 세는 중…') : job.pruning ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) : trf('{0} 안의 파일', [job.currentName]), job.current, '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', ), ], ]); } Future _askName(String title, String initial) { final ctl = TextEditingController(text: initial); final dot = initial.lastIndexOf('.'); ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 ? dot : initial.length); return showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(title), content: TextField( controller: ctl, autofocus: true, decoration: InputDecoration(hintText: tr('이름')), onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton( onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()), child: Text(tr('확인'))), ], ), ); } Future _info(FileEntry e) async { final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; if (!mounted) return; await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(e.name), content: SelectableText([ e.path, '${tr('크기')}: ${formatSize(size)}', if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', ].join('\n')), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], ), ); } Future _search(_Pane pane) async { final q = await _askName(trf('찾기: {0}', [pane.current]), ''); if (q == null || !mounted) return; final found = await showDialog( context: context, builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden), ); if (found == null) return; await _goTo(pane, found.isDir ? found.path : vDirname(found.path)); pane.focused = found.path; pane.changed(); } Future _sortDialog() async { final s = c.settings; var by = s.explorerSort; var desc = s.explorerSortDesc; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(tr('정렬 기준')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap(spacing: 6, children: [ for (final (v, label) in [ ('name', tr('이름')), ('date', tr('바뀐 날')), ('size', tr('크기')), ('type', tr('종류 (확장자)')), ]) ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)), ]), SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), ], ), ), ); if (ok != true) return; await c.updateSettings((x) => x ..explorerSort = by ..explorerSortDesc = desc); for (final pane in _panes) { for (final k in pane.cache.keys.toList()) { pane.cache[k] = _sorted(pane.cache[k]!); } pane.changed(); } } Future _historyDialog(_Pane pane) async { final hist = c.settings.explorerHistory.where(_reachable).toList(); final pick = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: Text(tr('내역 (최근 폴더)')), children: [ if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))), for (final h in hist) SimpleDialogOption( onPressed: () => Navigator.pop(ctx, h), child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis), ), ], ), ); if (pick != null) await _goTo(pane, pick); } Future _layoutDialog() async { final s = c.settings; var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) { Widget group(String title, String value, List<(String, String)> items, void Function(String) on) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 8, left: 4), child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), ), Wrap(spacing: 6, children: [ for (final (v, label) in items) ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))), ]), ]); return AlertDialog( scrollable: true, title: Text(tr('창 배치')), content: SizedBox( width: 420, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ group(tr('창'), layout, [ ('dual', tr('두 창')), ('split', tr('폴더 + 파일 목록')), ('single', tr('한 창')), ], (v) => layout = v), if (layout == 'split') Padding( padding: const EdgeInsets.only(top: 6, left: 4), child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), if (layout != 'single') group(tr('두 창 배치'), orient, [ ('auto', tr('화면 모양 따라')), ('side', tr('좌우')), ('stacked', tr('위아래')), ], (v) => orient = v), group(tr('기능 버튼 줄'), bar, [ ('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')), ('edge', tr('끝 (오른쪽 · 아래)')), ('hidden', tr('숨김')), ], (v) => bar = v), if (bar == 'hidden') Padding( padding: const EdgeInsets.only(top: 8, left: 4), child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), ], ); }, ), ); if (ok != true) return; await c.updateSettings((x) => x ..explorerLayout = layout ..explorerOrientation = orient ..explorerToolbar = bar); if (layout == 'single') setState(() => _active = 0); // 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게 for (final pane in _panes) { _reveal(pane, pane.current); } } Future _buttonsDialog() async { final current = ExplorerButton.fromSettings(c.settings.explorerButtons); final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))]; final on = {...current}; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( title: Text(tr('버튼 구성')), content: SizedBox( width: 380, height: 460, child: Column(children: [ Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 8), Expanded( child: ReorderableListView( buildDefaultDragHandles: true, onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))), children: [ for (final b in order) CheckboxListTile( key: ValueKey(b), dense: true, value: on.contains(b), secondary: Icon(b.icon), title: Text(tr(b.label)), controlAffinity: ListTileControlAffinity.leading, onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)), ), ], ), ), ]), ), actions: [ TextButton( onPressed: () => set(() { order ..clear() ..addAll(ExplorerButton.explorerValues); on ..clear() ..addAll(ExplorerButton.explorerValues); }), child: Text(tr('기본값')), ), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), ], ), ), ); if (ok != true) return; final names = [for (final b in order) if (on.contains(b)) b.name]; await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names); } // ───────── 화면 ───────── @override Widget build(BuildContext context) { return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh), const SingleActivator(LogicalKeyboardKey.delete): () => _button(ExplorerButton.delete), const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename), const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up), const SingleActivator(LogicalKeyboardKey.tab): () => setState(() => _active = _dual ? 1 - _active : 0), }, child: Focus( autofocus: true, child: Scaffold( body: SwipeNav( current: widget.rsync ? 'rsync' : 'explorer', child: ListenableBuilder( listenable: c, builder: (context, _) { _dropRemovedServers(); return Column(children: [ _topBar(), Expanded( child: Stack(children: [ Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()), ]), ), ]); }, ), ), ), ), ); } Widget _topBar() => AppTopBar( nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), actions: AppActions(c: c), middle: Row(children: [ Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), Expanded( child: Text(_ready ? vDisplay(_pane.current) : '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), ), PopupMenuButton( tooltip: tr('창 배치 · 버튼 구성'), icon: const Icon(Icons.more_vert), onSelected: _button, itemBuilder: (_) => [ for (final b in [ if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons], ExplorerButton.sort, ExplorerButton.hidden, ]) PopupMenuItem( value: b, child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]), ), ], ), ]), ); /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로 String _shape = ''; Widget _body() { final s = c.settings; final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}'; if (_ready && shape != _shape) { if (_shape.isNotEmpty) { for (final pane in _panes) { _reveal(pane, pane.current); } } _shape = shape; } final dual = _dual; final split = _split; return LayoutBuilder(builder: (context, box) { final side = !(dual || split) || switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight, }; final bar = s.explorerToolbar == 'hidden' && !widget.rsync ? null : _Toolbar( // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons), vertical: side, onPressed: _button, enabled: (b) => switch (b) { ExplorerButton.paste => _clip.isNotEmpty, _ => true, }, selected: (b) => (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting), ); final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle'); final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge'; final children = [ if (dual) ...[ Expanded(child: _paneView(0, foldersOnly: widget.rsync)), if (middle) bar, Expanded(child: _paneView(1, foldersOnly: widget.rsync)), if (edge) bar, ] else if (split) ...[ // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록 Expanded(flex: 2, child: _paneView(0, foldersOnly: true)), if (middle) bar, Expanded(flex: 3, child: _listView(_panes[0])), if (edge) bar, ] else ...[ if (middle) bar, Expanded(child: _paneView(0)), if (edge) bar, ], ]; return side ? Row(children: children) : Column(children: children); }); } Widget _frame(int i, List children) { final active = i == _active; return GestureDetector( onTapDown: (_) { if (_active != i) setState(() => _active = i); }, child: Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( color: JjColors.bg, border: Border.all(color: active ? JjColors.accent : JjColors.border, width: active ? 1.5 : 1), borderRadius: BorderRadius.circular(6), ), child: Column(children: children), ), ); } /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 Widget _marksBar(_Pane pane, {String? listDir}) => Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row(children: [ Icon(Icons.checklist, size: 18, color: JjColors.accent), const SizedBox(width: 6), Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)), const Spacer(), TextButton( onPressed: () => setState(() { final dir = listDir ?? pane.current; pane.marked.addAll([for (final x in pane.cache[dir] ?? const []) x.path]); _normalizeMarks(pane); }), child: Text(tr('모두 선택')), ), TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))), TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), ]), ); /// 트리 창 (저장 장치 고르기 + 트리) Widget _paneView(int i, {bool foldersOnly = false}) { final pane = _panes[i]; final rows = _rows(pane, foldersOnly: foldersOnly); final look = _look; return _frame(i, [ _paneHeader(pane), const Divider(height: 1), if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( child: ListView.builder( controller: pane.scroll, itemCount: rows.length, itemExtent: look.rowHeight, itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly), ), ), if (_selecting && !foldersOnly) _marksBar(pane), ]); } /// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더) Widget _listView(_Pane pane) { final dir = pane.current; final entries = pane.cache[dir] ?? const []; final up = !samePath(dir, pane.root); final rows = [ if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true), for (final e in entries) _Row(e, 0), ]; final look = _look; return _frame(0, [ Container( height: 40, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 12), child: Text(dir, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), ), const Divider(height: 1), if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( child: pane.loading.contains(dir) ? const Center(child: CircularProgressIndicator()) : entries.isEmpty && !up ? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim))) : ListView.builder( controller: pane.listScroll, itemCount: rows.length, itemExtent: look.rowHeight, itemBuilder: (_, k) => _rowView(pane, rows[k], list: true), ), ), if (_selecting) _marksBar(pane, listDir: dir), ]); } /// 창 위쪽: 저장 장치 고르기 Widget _paneHeader(_Pane pane) => SizedBox( height: 40, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), children: [ for (final (path, label) in _volumes) Padding( padding: const EdgeInsets.only(right: 4), child: TextButton.icon( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null, foregroundColor: samePath(pane.root, path) ? JjColors.accent : null, ), icon: Icon( isDav(path) ? Icons.cloud_outlined : Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16), label: Text(label), onPressed: () { setState(() => _active = _panes.indexOf(pane)); _goTo(pane, path); }, onLongPress: isDav(path) ? () => _editDav(pane, path) : null, ), ), if (c.settings.webdavServers.isEmpty) TextButton.icon( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), ), icon: const Icon(Icons.cloud_outlined, size: 16), label: const Text('WebDAV'), onPressed: () => _editDav(pane, null), ) else IconButton( tooltip: tr('WebDAV 서버 추가'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.add, size: 16), onPressed: () => _editDav(pane, null), ), ], ), ); /// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. Future _editDav(_Pane pane, String? root) async { setState(() => _active = _panes.indexOf(pane)); final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server); final saved = await editDavServer(context, c, old: old); if (saved == null || !mounted) return; for (final x in _panes) { x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id); } await _goTo(pane, '$davScheme${saved.id}/'); } /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) { final e = row.entry; final look = _look; final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); final marked = _isMarked(pane, e.path); final open = e.isDir && !list && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final name = row.isUp ? '..' : row.isRoot ? (_volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) : look == ExplorerStyle.totalcmd ? totalCmdName(e) : e.name; final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim); final nameStyle = TextStyle( fontSize: look.fontSize, fontWeight: isCurrent || (look == ExplorerStyle.totalcmd && e.isDir) ? FontWeight.w600 : FontWeight.normal, color: isCurrent ? JjColors.accent : null, ); // 트리의 펼치기 화살표 (따로 누름) Widget chevron() => SizedBox( width: 20, child: e.isDir && !list && !row.isUp ? (loading ? const Center( child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) : InkResponse( radius: 16, onTap: () => _toggle(pane, e), child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim), )) : null, ); Widget nameCell() => Row(children: [ SizedBox(width: list ? 0 : row.depth * look.indent), if (!list) chevron(), SizedBox( width: look.rich ? 56 : look.iconSize + 6, height: look.rowHeight - 8, child: Center(child: _icon(e, row.isRoot, open, up: row.isUp)), ), SizedBox(width: look.rich ? 8 : 6), Expanded( child: look.rich && !e.isDir && !row.isUp ? Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle), Row(children: [ Text('${formatSize(e.size)} · ${_date(e.modified)}', style: dim), if (isVideoFile(e.path)) ...[ const SizedBox(width: 8), Flexible(child: _MetaText(c: c, path: e.path)), ], ]), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle), if (look.rich && e.isDir && pane.errors[e.path] != null) Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent)), ], ), ), ]); final real = !row.isRoot && !row.isUp; Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded( flex: flex, child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis), ); final cells = !look.columns || compact || !real ? [Expanded(child: nameCell())] : look == ExplorerStyle.windows ? [ Expanded(flex: 6, child: nameCell()), cell(_date(e.modified), 3), cell(typeLabel(e), 2), cell(e.isDir ? '' : formatSize(e.size), 2, align: TextAlign.end), ] : [ Expanded(flex: 6, child: nameCell()), cell(e.isDir ? '' : e.ext, 1), cell(e.isDir ? '' : formatSize(e.size), 2, align: TextAlign.end), const SizedBox(width: 8), cell(_date(e.modified), 3), ]; return GestureDetector( // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) onSecondaryTapUp: real && !widget.rsync ? (d) => _menu(pane, e, d.globalPosition) : null, onLongPressStart: real && !widget.rsync ? (d) => _menu(pane, e, d.globalPosition) : null, child: Material( color: marked ? JjColors.accent.withValues(alpha: 0.18) : isFocused ? JjColors.panel : Colors.transparent, child: Row(children: [ Expanded( child: InkWell( onTap: () => _onTap(pane, row, list: list), // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게) // 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게) onDoubleTap: _selectMode && !_selecting && !e.isDir && !row.isUp ? () => _onOpen(pane, row, list: list) : null, child: Padding( padding: const EdgeInsets.only(left: 6, right: 4), child: Row(children: cells), ), ), ), // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync)) SizedBox( width: 36, child: IconButton( tooltip: widget.rsync ? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기')) : marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, // ✔ 모양: 고르지 않은 것은 흐리게, 고른 것은 진하게 icon: Icon(marked ? Icons.check_circle : Icons.check_circle_outline, size: look.rich ? 22 : 18, color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)), onPressed: () { setState(() => _active = _panes.indexOf(pane)); widget.rsync ? _pick(pane, e) : _toggleMark(pane, e); }, ), ) else if (_selecting || widget.rsync) const SizedBox(width: 36), ]), ), ); } Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { final look = _look; if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim); if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2); // X-plore: 동영상 썸네일 · 그림 미리 보기 (WebDAV 는 받아야 하므로 아이콘만) if (look.rich && !e.isDir && !isDav(e.path)) { if (isVideoFile(e.path)) return _Thumb(c: c, entry: e); const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'}; if (images.contains(e.ext)) { return ClipRRect( borderRadius: BorderRadius.circular(3), child: Image.file(File(e.path), width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112, errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)), ); } } final (icon, color) = fileIcon(look, e, open: open); return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color); } } String _date(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; /// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) class _Toolbar extends StatelessWidget { final List buttons; final bool vertical; final void Function(ExplorerButton) onPressed; final bool Function(ExplorerButton) enabled; final bool Function(ExplorerButton) selected; const _Toolbar( {required this.buttons, required this.vertical, required this.onPressed, required this.enabled, required this.selected}); @override Widget build(BuildContext context) { Widget btn(ExplorerButton b) { final on = enabled(b); final sel = selected(b); return Tooltip( message: tr(b.label), child: InkWell( onTap: on ? () => onPressed(b) : null, borderRadius: BorderRadius.circular(6), child: SizedBox( width: 72, height: 58, child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(b.icon, size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null), const SizedBox(height: 2), Text(tr(b.label), maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), ]), ), ), ); } return Container( color: JjColors.panel, width: vertical ? 76 : null, height: vertical ? null : 62, child: SingleChildScrollView( scrollDirection: vertical ? Axis.vertical : Axis.horizontal, padding: const EdgeInsets.all(2), child: vertical ? Column(children: [for (final b in buttons) btn(b)]) : Row(children: [for (final b in buttons) btn(b)]), ), ); } } /// 찾기 결과 (찾는 대로 보여 주고, 누르면 그 자리로) class _SearchDialog extends StatefulWidget { final String root; final String query; final bool showHidden; const _SearchDialog({required this.root, required this.query, required this.showHidden}); @override State<_SearchDialog> createState() => _SearchDialogState(); } class _SearchDialogState extends State<_SearchDialog> { final _found = []; StreamSubscription? _sub; bool _done = false; @override void initState() { super.initState(); _sub = searchFiles(widget.root, widget.query, showHidden: widget.showHidden).listen( (e) => setState(() => _found.add(e)), onDone: () => setState(() => _done = true), onError: (_) {}, ); } @override void dispose() { _sub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) => AlertDialog( title: Row(children: [ Expanded(child: Text(trf('"{0}" 찾기 · {1}개', [widget.query, _found.length]))), if (!_done) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), ]), content: SizedBox( width: 560, height: 420, child: _found.isEmpty && _done ? Center(child: Text(tr('찾은 것이 없습니다.'))) : ListView.builder( itemCount: _found.length, itemBuilder: (_, i) { final e = _found[i]; return ListTile( dense: true, leading: Icon(e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, color: e.isDir ? Colors.amber : null), title: Text(e.name), subtitle: Text(vDirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis), onTap: () => Navigator.pop(context, e), ); }, ), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기')))], ); } // ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ───────── /// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게) class _Queue { final int max; int _running = 0; final _waiting = Queue>(); _Queue(this.max); Future run(Future Function() job) async { if (_running >= max) { final w = Completer(); _waiting.add(w); await w.future; } _running++; try { return await job(); } finally { _running--; if (_waiting.isNotEmpty) _waiting.removeFirst().complete(); } } } /// "1920×1080 · 1:23:45" class _Meta { static final _cache = >{}; static final _q = _Queue(2); static Future of(AppController c, String path) => _cache[path] ??= _q.run(() async { try { final info = await c.services.mediaTool.probe(path); final v = info.ofType('video').where((s) => (s.width ?? 0) > 0).firstOrNull; final d = info.duration; return [ if (v != null) '${v.width}×${v.height}', if (d != null) _dur(d), ].join(' · '); } catch (_) { return ''; } }); static String _dur(Duration d) { final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60; String two(int x) => x.toString().padLeft(2, '0'); return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}'; } } class _MetaText extends StatelessWidget { final AppController c; final String path; const _MetaText({required this.c, required this.path}); @override Widget build(BuildContext context) => FutureBuilder( future: _Meta.of(c, path), builder: (_, s) => Text(s.data ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: JjColors.accent)), ); } /// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀). class _Thumbs { static final _cache = >{}; static final _q = _Queue(1); static Future of(AppController c, FileEntry e) { final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}'; return _cache[key] ??= _q.run(() async { try { final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs')); await dir.create(recursive: true); final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg')); if (await out.exists() && await out.length() > 0) return out; if (c.busy) { _cache.remove(key); // 작업이 끝나면 다시 시도 return null; } for (final at in ['5', '0']) { await c.services.mediaTool.runFfmpeg([ '-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, // '-frames:v', '1', '-vf', 'scale=160:-2', '-y', out.path, ]); if (await out.exists() && await out.length() > 0) return out; } } catch (_) {} return null; }); } } class _Thumb extends StatelessWidget { final AppController c; final FileEntry entry; const _Thumb({required this.c, required this.entry}); @override Widget build(BuildContext context) => FutureBuilder( future: _Thumbs.of(c, entry), builder: (_, s) { final f = s.data; if (f == null) return const Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim); return ClipRRect( borderRadius: BorderRadius.circular(3), child: Stack(alignment: Alignment.center, children: [ Image.file(f, width: 56, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => const Icon(Icons.movie_outlined, size: 28)), const Icon(Icons.play_arrow, size: 18, color: Colors.white70), ]), ); }, ); }