JJ_MKVMaker/lib/ui/explorer_page.dart

2203 lines
88 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 '../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<void> open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false);
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
static Future<void> _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<void>(
settings: RouteSettings(name: name),
builder: (_) => ExplorerPage(c: c, rsync: rsync),
));
}
@override
State<ExplorerPage> 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<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly) b];
/// Rsync 화면의 버튼 (늘 이대로, 두 창 사이)
static const rsyncButtons = [up, refresh, newFolder, toRight, toLeft, both, monitor];
static List<ExplorerButton> fromSettings(List<String> 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 = <String>{};
final cache = <String, List<FileEntry>>{};
final loading = <String>{};
final errors = <String, String>{};
final marked = <String>{};
/// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게)
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<ExplorerPage> {
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<void> _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<List<(String, String)>> _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 는 그 서버가 아직 설정에 있으면 (열 때 확인)
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<void> _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<FileEntry> _sorted(List<FileEntry> list) {
final by = SortBy.values.firstWhere((s) => s.name == c.settings.explorerSort, orElse: () => SortBy.name);
return sortEntries(list, by, descending: c.settings.explorerSortDesc);
}
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다
Future<void> _goTo(_Pane pane, String dir, {bool remember = true}) async {
pane.root = _volumeOf(dir);
final chain = <String>[];
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 <FileEntry>[]) {
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<void> _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<void> _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<void> _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 = <String>[];
var dir = owner;
while (!samePath(dir, e.path)) {
await _load(pane, dir);
String? next;
for (final x in pane.cache[dir] ?? const <FileEntry>[]) {
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<void> _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);
}
/// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기)
Future<void> _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<void> _open(String path) async {
if (isVideoFile(path)) {
await _play([path]);
return;
}
final local = await _fetch(path);
if (local == null) return;
final ok = await c.services.shell.openWith(local);
if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
}
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (받는 동안 진행 창 · 취소). 로컬은 그대로.
Future<String?> _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<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
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<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
if (!mounted || paths.isEmpty) return;
await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder);
}
Future<void> _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);
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 (!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 목록에 추가')),
('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<String>(
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 '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<String> _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<void> _refresh(_Pane pane, Iterable<String> dirs) async {
for (final d in {...dirs}) {
if (pane.cache.containsKey(d)) await _load(pane, d, force: true);
}
}
/// 바뀐 폴더를 두 창 모두 다시 읽는다
Future<void> _refreshAll(Iterable<String> dirs) async {
for (final pane in _panes) {
await _refresh(pane, dirs);
}
}
Future<void> _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<void> _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<void> _addMkv(List<String> paths) async {
if (paths.any(isDav)) {
_snack(tr('WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
return;
}
final videos = <String>[];
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<void> _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<void> _delete(_Pane pane, List<String> paths) async {
if (paths.isEmpty) {
_snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
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<void> _transfer(_Pane pane, List<String> 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<String> _clip = [];
bool _clipMove = false;
void _toClipboard(List<String> 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<void> _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<TransferJob> _jobs = const [];
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
bool _viaDav(Iterable<String> paths) => paths.any(isDav);
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> _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<bool> _runTransfer(_Pane pane, List<String> 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<bool>(
context: context,
builder: (ctx) => AlertDialog(
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<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && _jobs.contains(job)) setState(() => _jobs = const []);
return error == null;
}
/// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께).
/// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u).
/// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로).
Future<void> _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<bool>(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog(
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 = <TransferJob>[];
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<Object>().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<void>.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<String?> _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<String>(
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<void> _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<void>(
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<void> _search(_Pane pane) async {
final q = await _askName(trf('찾기: {0}', [pane.current]), '');
if (q == null || !mounted) return;
final found = await showDialog<FileEntry>(
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<void> _sortDialog() async {
final s = c.settings;
var by = s.explorerSort;
var desc = s.explorerSortDesc;
final ok = await showDialog<bool>(
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<void> _historyDialog(_Pane pane) async {
final hist = c.settings.explorerHistory.where(_reachable).toList();
final pick = await showDialog<String>(
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<void> _layoutDialog() async {
final s = c.settings;
var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar;
final ok = await showDialog<bool>(
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<void> _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<bool>(
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: 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: [
AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
const SizedBox(width: 8),
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<ExplorerButton>(
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))]),
),
],
),
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 = _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 = <Widget>[
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<Widget> 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 <FileEntry>[]) 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 <FileEntry>[];
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<void> _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
? <Widget>[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 ? '<DIR>' : 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<ExplorerButton> 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 = <FileEntry>[];
StreamSubscription<FileEntry>? _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<Completer<void>>();
_Queue(this.max);
Future<T> run<T>(Future<T> Function() job) async {
if (_running >= max) {
final w = Completer<void>();
_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 = <String, Future<String>>{};
static final _q = _Queue(2);
static Future<String> 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<String>(
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 = <String, Future<File?>>{};
static final _q = _Queue(1);
static Future<File?> 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<File?>(
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),
]),
);
},
);
}