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 '../core/file_ops.dart'; import '../core/playlist.dart' show isVideoFile; import '../platform/android/android_storage.dart'; import 'app_actions.dart'; import 'player_page.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, '새로 고침'), 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, '이동'), 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(); _Pane(this.root) : current = root; void changed() => notifyListeners(); @override void dispose() { scroll.dispose(); super.dispose(); } } /// 트리 목록의 한 줄 class _Row { final FileEntry entry; final int depth; final bool isRoot; const _Row(this.entry, this.depth, {this.isRoot = 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(); _reveal(pane, dir); if (remember) _remember(pane, dir); } /// 목록 줄 높이 static const _rowHeight = 52.0; /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) void _reveal(_Pane pane, String path) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !pane.scroll.hasClients) return; final i = _rows(pane).indexWhere((r) => samePath(r.entry.path, path)); if (i < 0) return; final pos = pane.scroll.position; final want = (i * _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; } List<_Row> _rows(_Pane pane) { 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 []) { out.add(_Row(e, depth)); if (e.isDir) walk(e.path, depth + 1); } } walk(pane.root, 1); return out; } // ───────── 누르기 ───────── Future _tap(_Pane pane, FileEntry e) async { setState(() => _active = _panes.indexOf(pane)); pane.focused = e.path; if (e.isDir) { if (pane.expanded.contains(e.path) && samePath(pane.current, e.path)) { pane.expanded.remove(e.path); // 지금 폴더를 다시 누르면 접기 pane.current = p.dirname(e.path).length < pane.root.length ? pane.root : p.dirname(e.path); pane.changed(); } else { pane ..expanded.add(e.path) ..current = e.path; await _load(pane, e.path); _remember(pane, e.path); } return; } pane.current = p.dirname(e.path); pane.changed(); await _open(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 목록에 추가')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')), ('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 '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 '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.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.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); 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) { _snack(tr('복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).')); return; } if (sources.isEmpty) { _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return; } final dest = other.current; 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])), 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; final ops = FileOps(); final progress = ValueNotifier<(String, double)>(('', 0)); showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: Text(move ? tr('옮기는 중') : tr('복사하는 중')), content: ValueListenableBuilder<(String, double)>( valueListenable: progress, builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(p.basename(v.$1), maxLines: 1, overflow: TextOverflow.ellipsis), const SizedBox(height: 10), LinearProgressIndicator(value: v.$2 <= 0 ? null : v.$2), const SizedBox(height: 6), Text('${(v.$2 * 100).round()}%', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ]), ), actions: [TextButton(onPressed: ops.cancel, child: Text(tr('취소')))], ), ); Object? error; var made = []; try { void onP(String f, int done, int total) => progress.value = (f, total == 0 ? 0 : done / total); made = move ? await ops.move(sources, dest, onProgress: onP) : await ops.copy(sources, dest, onProgress: onP); } on FileOpCancelled { error = tr('취소했습니다.'); } catch (e) { error = e; } if (mounted) Navigator.of(context).pop(); pane.marked.clear(); await _refreshAll([dest, ...sources.map(p.dirname)]); other.expanded.add(dest); other.changed(); _snack(error != null ? trf('끝나지 못했습니다: {0}', [error]) : trf('{0}개 항목을 {1}', [made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); } 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('두 창')), ('single', tr('한 창'))], (v) => layout = v), if (layout == 'dual') group(tr('두 창 배치'), orient, [ ('auto', tr('화면 모양 따라')), ('side', tr('좌우')), ('stacked', tr('위아래')), ], (v) => orient = v), group(tr('기능 버튼 줄'), bar, [ ('middle', layout == 'dual' ? 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: _ready ? _body() : const Center(child: CircularProgressIndicator())), ]), ), ), ), ); } 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), ]), ); Widget _body() { final s = c.settings; final dual = s.explorerLayout == 'dual'; return LayoutBuilder(builder: (context, box) { final side = !dual || switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight, }; final bar = s.explorerToolbar == 'hidden' ? null : _Toolbar( buttons: ExplorerButton.fromSettings(s.explorerButtons), vertical: side, onPressed: _button, enabled: (b) => switch (b) { ExplorerButton.copy || ExplorerButton.move => dual, _ => true, }, selected: (b) => b == ExplorerButton.hidden && s.explorerShowHidden, ); Widget pane(int i) => Expanded(child: _paneView(i)); final children = [ if (dual) ...[ pane(0), if (bar != null && s.explorerToolbar == 'middle') bar, pane(1), if (bar != null && s.explorerToolbar == 'edge') bar, ] else ...[ if (bar != null && s.explorerToolbar == 'middle') bar, pane(0), if (bar != null && s.explorerToolbar == 'edge') bar, ], ]; return side ? Row(children: children) : Column(children: children); }); } Widget _paneView(int i) { final pane = _panes[i]; final active = i == _active; final rows = _rows(pane); 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: [ _paneHeader(pane), const Divider(height: 1), Expanded( child: ListView.builder( controller: pane.scroll, itemCount: rows.length, itemExtent: _rowHeight, itemBuilder: (_, k) => _rowView(pane, rows[k]), ), ), if (pane.marked.isNotEmpty) Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row(children: [ Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)), const Spacer(), TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))), ]), ), ]), ), ); } /// 창 위쪽: 저장 장치 고르기 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); }, ), ), ], ), ); Widget _rowView(_Pane pane, _Row row) { final e = row.entry; final isCurrent = e.isDir && samePath(pane.current, e.path); final isFocused = pane.focused != null && samePath(pane.focused!, e.path); final marked = pane.marked.contains(e.path); final open = e.isDir && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final video = !e.isDir && isVideoFile(e.path); final name = row.isRoot ? (_volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) : e.name; return GestureDetector( onSecondaryTapUp: (d) => _menu(pane, e, d.globalPosition), onLongPressStart: (d) => _menu(pane, e, d.globalPosition), child: Material( color: marked ? JjColors.accent.withValues(alpha: 0.18) : isFocused ? JjColors.panel : Colors.transparent, child: InkWell( onTap: () => _tap(pane, e), child: Padding( padding: EdgeInsets.only(left: 6.0 + row.depth * 16, right: 4), child: Row(children: [ SizedBox( width: 18, child: e.isDir ? (loading ? const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)) : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)) : null, ), const SizedBox(width: 4), SizedBox(width: 56, height: 44, child: Center(child: _icon(e, row.isRoot, open))), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, color: isCurrent ? JjColors.accent : null)), if (!e.isDir) Row(children: [ Text('${formatSize(e.size)} · ${_date(e.modified)}', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), if (video) ...[ const SizedBox(width: 8), Flexible(child: _MetaText(c: c, path: e.path)), ], ]), if (e.isDir && pane.errors[e.path] != null) Text(tr('읽을 수 없음'), style: const TextStyle(fontSize: 11, color: Colors.redAccent)), ], ), ), if (!row.isRoot) IconButton( tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'), visualDensity: VisualDensity.compact, icon: Icon(marked ? Icons.check_circle : Icons.radio_button_unchecked, size: 20, color: marked ? JjColors.accent : JjColors.textDim), onPressed: () => setState(() { _active = _panes.indexOf(pane); marked ? pane.marked.remove(e.path) : pane.marked.add(e.path); }), ), ]), ), ), ), ); } Widget _icon(FileEntry e, bool isRoot, bool open) { if (isRoot) return const Icon(Icons.sd_storage, color: JjColors.accent, size: 28); if (e.isDir) return Icon(open ? Icons.folder_open : Icons.folder, color: Colors.amber, size: 30); 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 = switch (e.ext) { 'srt' || 'ass' || 'ssa' || 'smi' || 'sami' || 'vtt' => Icons.subtitles_outlined, 'mp3' || 'm4a' || 'flac' || 'wav' || 'ogg' || 'aac' || 'opus' => Icons.audiotrack_outlined, 'pdf' => Icons.picture_as_pdf_outlined, 'zip' || '7z' || 'rar' || 'tar' || 'gz' => Icons.folder_zip_outlined, 'apk' => Icons.android, 'txt' || 'log' || 'md' || 'json' || 'xml' => Icons.description_outlined, _ => Icons.insert_drive_file_outlined, }; return Icon(icon, size: 28, color: JjColors.textDim); } } 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), ]), ); }, ); }