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/transfer_job.dart'; import '../core/sync_tools.dart'; import '../core/file_ops.dart'; import '../core/playlist.dart' show isVideoFile; 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 '../l10n/tr.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// /// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더". /// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램), /// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …). /// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로. /// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴). class ExplorerPage extends StatefulWidget { final AppController c; const ExplorerPage({super.key, required this.c}); static const routeName = 'explorer'; static int _open = 0; /// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로) static Future open(NavigatorState nav, {required AppController c}) async { if (_open > 0) { var found = false; nav.popUntil((r) { if (r.settings.name == routeName) found = true; return found || r.isFirst; }); if (found) return; } await nav.push(MaterialPageRoute( settings: const RouteSettings(name: routeName), builder: (_) => ExplorerPage(c: c), )); } @override State createState() => _ExplorerPageState(); } /// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. enum ExplorerButton { up(Icons.arrow_upward, '상위 폴더'), refresh(Icons.refresh, '새로 고침'), select(Icons.checklist, '선택'), 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); static List fromSettings(List names) { final out = [ for (final n in names) for (final b in values) if (b.name == n) b, ]; return out.isEmpty ? values.toList() : 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)> _volumes = []; late final List<_Pane> _panes = [_Pane(''), _Pane('')]; int _active = 0; bool _ready = false; @override void initState() { super.initState(); ExplorerPage._open++; for (final pane in _panes) { pane.addListener(_onPane); } _init(); } @override void dispose() { ExplorerPage._open--; for (final pane in _panes) { pane.dispose(); } super.dispose(); } void _onPane() { if (mounted) setState(() {}); } Future _init() async { _volumes = await _loadVolumes(); if (_volumes.isEmpty) _volumes = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; final saved = c.settings.explorerPaths; for (var i = 0; i < 2; i++) { final want = i < saved.length && Directory(saved[i]).existsSync() ? saved[i] : _volumes[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 [('/', '/')]; } String _volumeOf(String path) { String best = _volumes.first.$1; var len = -1; for (final (v, _) in _volumes) { 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 = []; var d = p.normalize(dir); while (true) { chain.insert(0, d); if (samePath(d, pane.root) || p.dirname(d) == d) break; d = p.dirname(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; final i = _rows(pane, foldersOnly: _split).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)]; 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 => c.settings.explorerLayout == 'split'; /// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기) 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 (!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 = p.dirname(e.path); pane.changed(); } /// 선택 모드에서 누르기: 파일은 고르기 · 풀기. /// 폴더는 처음엔 고르기, 고른 폴더를 다시 누르면 폴더는 풀고 펼쳐서 그 안의 파일 · 폴더를 모두 고른다. Future _toggleMark(_Pane pane, FileEntry e) async { pane.focused = e.path; if (!pane.marked.contains(e.path)) { setState(() => pane.marked.add(e.path)); return; } setState(() => pane.marked.remove(e.path)); if (!e.isDir) return; await _load(pane, e.path); setState(() { pane.expanded.add(e.path); pane.marked.addAll([for (final x in pane.cache[e.path] ?? const []) x.path]); }); } /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. 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 = p.dirname(e.path); pane.changed(); await _open(e.path); } /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) 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) async { if (isVideoFile(path)) { await playFiles(context, c, [path]); return; } final ok = await c.services.shell.openWith(path); if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); } 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 dual = c.settings.explorerLayout == '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 (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), ('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}개)', [p.basename(e.isDir ? e.path : p.dirname(e.path)), _clip.length])), ('delete', Icons.delete_outline, tr('삭제')), if (!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 playFiles(context, c, [e.path], internal: true); case 'openWith': if (!await c.services.shell.openWith(e.path, choose: true)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); case 'openDefault': if (!await c.services.shell.openWith(e.path)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); case 'addMkv': await _addMkv([e.path]); case 'select': setState(() => _selecting = true); if (!pane.marked.contains(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(pane.marked.contains(e.path) ? pane.marked.toList() : [e.path], move: false); case 'clipMove': _toClipboard(pane.marked.contains(e.path) ? pane.marked.toList() : [e.path], move: true); case 'paste': await _paste(pane, e.isDir ? e.path : p.dirname(e.path)); case 'delete': await _delete(pane, [e.path]); case 'reveal': await c.services.shell.revealFile(e.path); case 'info': await _info(e); } } // ───────── 기능 ───────── _Pane get _pane => _panes[_active]; _Pane? get _other => c.settings.explorerLayout == '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 = p.dirname(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.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) => !FileSystemEntity.isDirectorySync(x) && isVideoFile(x)).toList(); if (videos.isNotEmpty) { await playFiles(context, c, videos, keepOrder: pane.marked.isNotEmpty); } else { await _playFolder(t.length == 1 && FileSystemEntity.isDirectorySync(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 made = await FileOps.makeFolder(pane.current, name); await _refreshAll([pane.current]); pane.focused = made; pane.changed(); } 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 playFiles(context, c, list); } Future _addMkv(List paths) async { final videos = []; for (final x in paths) { if (FileSystemEntity.isDirectorySync(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('이름 변경'), p.basename(path)); if (name == null || name == p.basename(path)) return; try { final now = await FileOps.rename(path, name); await _refreshAll([p.dirname(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( title: Text(tr('삭제')), // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) content: Text(paths.length == 1 ? '${trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [p.basename(paths.first)])}\n${p.dirname(paths.first)}' : [ trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]), for (final x in paths.take(8)) '· ${p.basename(x)}', if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), {for (final x in paths) p.dirname(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 = p.dirname(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 = []); } /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) TransferJob? _job; /// 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 (_job != null && !_job!.finished) { _snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.')); return false; } // 복사 모니터링을 켜면 기억한 같은 복사의 옵션을 쓰고, 처음이면 기억한다 final center = CopyCenter.of(c); final task = c.settings.copyMonitor ? await center.remember(sources, dest, move: move) : 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( title: Text(move ? tr('이동') : tr('복사')), content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), 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) { rsync = await _ensureRsync(); if (rsync == null || !mounted) return false; } final job = await center.start(task, rsyncExe: rsync); if (job == null || !mounted) return false; setState(() => _job = 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 && _job == job) setState(() => _job = null); return error == null; } /// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) Widget _transferPanel() { final job = _job; return AnimatedSlide( offset: job == null ? const Offset(0, 1.2) : Offset.zero, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: job == null ? const SizedBox(height: 0) : ListenableBuilder( listenable: job, builder: (context, _) { 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 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: [ 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( child: Text(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('취소'))), ]), line( trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]), job.overall, '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', ), line( job.counting ? 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( 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 && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; if (!mounted) return; await showDialog( context: context, builder: (ctx) => AlertDialog( 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 : p.dirname(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( 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((h) => Directory(h).existsSync()).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( 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.values.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.values); on ..clear() ..addAll(ExplorerButton.values); }), 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 = c.settings.explorerLayout == 'dual' ? 1 - _active : 0), }, child: Focus( autofocus: true, child: Scaffold( body: ListenableBuilder( listenable: c, builder: (context, _) => 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() => Container( height: appBarHeight, color: JjColors.panel, padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), child: Row(children: [ const AppNavButtons(onExplorerPage: true), const SizedBox(width: 8), Text(tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), Expanded( child: Text(_ready ? _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 [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))]), ), ], ), AppActions(c: c), ]), ); /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로 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 = s.explorerLayout == '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' ? null : _Toolbar( // [모니터링] 은 환경 설정 > 복사 모니터링 을 켰을 때만 buttons: [ for (final b in ExplorerButton.fromSettings(s.explorerButtons)) if (b != ExplorerButton.monitor || s.copyMonitor) b, ], 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 && s.explorerToolbar == 'middle'; final edge = bar != null && s.explorerToolbar == 'edge'; final children = [ if (dual) ...[ Expanded(child: _paneView(0)), if (middle) bar, Expanded(child: _paneView(1)), 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]); }), 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(p.dirname(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(Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16), label: Text(label), onPressed: () { setState(() => _active = _panes.indexOf(pane)); _goTo(pane, path); }, ), ), ], ), ); /// 한 줄. [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 = pane.marked.contains(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( onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null, onLongPressStart: real ? (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 && _selecting) SizedBox( width: 36, child: IconButton( tooltip: 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)); _toggleMark(pane, e); }, ), ) else if (_selecting) 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(Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2); // X-plore: 동영상 썸네일 · 그림 미리 보기 if (look.rich && !e.isDir) { 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(p.dirname(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), ]), ); }, ); }