2229 lines
89 KiB
Dart
2229 lines
89 KiB
Dart
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 서버를 보던 창은 이 기기의 첫 저장 장치로
|
||
void _dropRemovedServers() {
|
||
if (!_ready) return;
|
||
for (final pane in _panes) {
|
||
if (isDav(pane.root) && DavRegistry.server(DavPath.parse(pane.root).server) == null) {
|
||
pane.marked.clear();
|
||
pane.cache.removeWhere((k, _) => isDav(k));
|
||
pane.expanded.removeWhere(isDav);
|
||
pane.root = _local.first.$1; // 다시 그릴 때 또 하지 않게
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _goTo(pane, _local.first.$1);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인)
|
||
bool _reachable(String path) => isDav(path)
|
||
? DavRegistry.server(DavPath.parse(path).server) != null
|
||
: Directory(path).existsSync();
|
||
|
||
String _volumeOf(String path) {
|
||
if (isDav(path)) return '$davScheme${DavPath.parse(path).server}/';
|
||
String best = _local.first.$1;
|
||
var len = -1;
|
||
for (final (v, _) in _local) {
|
||
final inside = samePath(v, path) || p.isWithin(v, path) ||
|
||
(Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
|
||
if (inside && v.length > len) {
|
||
best = v;
|
||
len = v.length;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ───────── 목록 읽기 · 이동 ─────────
|
||
|
||
Future<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(
|
||
scrollable: true,
|
||
title: Text(tr('WebDAV 에서 받는 중')),
|
||
content: ValueListenableBuilder<(int, int)>(
|
||
valueListenable: progress,
|
||
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(vBasename(path)),
|
||
const SizedBox(height: 10),
|
||
LinearProgressIndicator(value: v.$2 > 0 ? v.$1 / v.$2 : null),
|
||
const SizedBox(height: 6),
|
||
Text('${formatSize(v.$1)}${v.$2 > 0 ? ' / ${formatSize(v.$2)}' : ''}',
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
]),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
cancelled = true;
|
||
Navigator.pop(ctx);
|
||
},
|
||
child: Text(tr('취소')),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
try {
|
||
final local = await vLocalCopy(path, temp, onProgress: (d, t) => progress.value = (d, t));
|
||
return cancelled ? null : local;
|
||
} catch (e) {
|
||
if (!cancelled) _snack(trf('받지 못했습니다: {0}', [e]));
|
||
return null;
|
||
} finally {
|
||
if (!cancelled && mounted) Navigator.of(context).pop();
|
||
await dialog;
|
||
progress.dispose();
|
||
}
|
||
}
|
||
|
||
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
|
||
Future<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(
|
||
scrollable: true,
|
||
title: Text(tr('삭제')),
|
||
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
|
||
content: Text(paths.length == 1
|
||
? '${trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)])}\n${vDirname(paths.first)}'
|
||
: [
|
||
trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]),
|
||
for (final x in paths.take(8)) '· ${vBasename(x)}',
|
||
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
|
||
{for (final x in paths) vDirname(x)}.join('\n'),
|
||
].join('\n')),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(tr('삭제')),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
try {
|
||
await FileOps().delete(paths);
|
||
} catch (e) {
|
||
_snack(trf('지우지 못했습니다: {0}', [e]));
|
||
}
|
||
for (final x in _panes) {
|
||
x.marked.removeAll(paths);
|
||
if (_selecting && x.marked.isEmpty) _selecting = false;
|
||
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
|
||
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first);
|
||
}
|
||
await _refreshAll(paths.map(p.dirname));
|
||
}
|
||
|
||
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
|
||
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
|
||
Future<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(
|
||
scrollable: true,
|
||
title: Text(move ? tr('이동') : tr('복사')),
|
||
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}'
|
||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
|
||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))),
|
||
],
|
||
),
|
||
);
|
||
if (go != true || !mounted) return false;
|
||
String? rsync;
|
||
if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) {
|
||
rsync = await _ensureRsync();
|
||
if (rsync == null || !mounted) return false;
|
||
}
|
||
final job = await center.start(task, rsyncExe: rsync);
|
||
if (job == null || !mounted) return false;
|
||
setState(() => _jobs = [job]);
|
||
await job.done;
|
||
final error = job.error;
|
||
if (!mounted) return false;
|
||
pane.marked.clear();
|
||
if (_selecting) _setSelecting(false);
|
||
await _refreshAll([dest, ...sources.map(p.dirname)]);
|
||
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
|
||
for (final x in _panes) {
|
||
if (samePath(x.current, dest)) {
|
||
x.expanded.add(dest);
|
||
await _load(x, dest, force: true);
|
||
}
|
||
x.changed();
|
||
}
|
||
_snack(error is FileOpCancelled
|
||
? tr('취소했습니다.')
|
||
: error != null
|
||
? trf('끝나지 못했습니다: {0}', [error])
|
||
: trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
|
||
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
|
||
await Future<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(
|
||
scrollable: true,
|
||
title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'),
|
||
content: SizedBox(
|
||
width: 560,
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
for (final t in tasks) ...[
|
||
Text('${vDisplay(t.sources.first)}/', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
Text('→ ${vDisplay(t.dest)}/', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}'
|
||
'${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
|
||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||
const SizedBox(height: 10),
|
||
],
|
||
Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'),
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
if (_viaDav([l, r]))
|
||
Text(tr('WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).'),
|
||
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
||
if (both)
|
||
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim))
|
||
else ...[
|
||
CheckboxListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
value: removeSource,
|
||
onChanged: (v) => set(() => removeSource = v ?? false),
|
||
title: Text(tr('원본 파일 지우기 (--remove-source-files)')),
|
||
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
|
||
),
|
||
if (removeSource) ...[
|
||
Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'),
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
const SizedBox(height: 6),
|
||
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
|
||
],
|
||
],
|
||
]),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(removeSource ? tr('이동') : tr('실행'))),
|
||
],
|
||
)),
|
||
);
|
||
if (go != true || !mounted) return;
|
||
final dav = _viaDav([l, r]);
|
||
final exe = dav ? '' : await _ensureRsync();
|
||
if (exe == null || !mounted) return;
|
||
tasks = [
|
||
for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource),
|
||
];
|
||
if (removeSource) {
|
||
tasks = [for (final t in tasks) t.copyWith(prune: prune)];
|
||
for (final t in tasks) {
|
||
await center.update(t);
|
||
}
|
||
}
|
||
final jobs = <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(
|
||
scrollable: true,
|
||
title: Text(title),
|
||
content: TextField(
|
||
controller: ctl,
|
||
autofocus: true,
|
||
decoration: InputDecoration(hintText: tr('이름')),
|
||
onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()),
|
||
child: Text(tr('확인'))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<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(
|
||
scrollable: true,
|
||
title: Text(e.name),
|
||
content: SelectableText([
|
||
e.path,
|
||
'${tr('크기')}: ${formatSize(size)}',
|
||
if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}',
|
||
if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
|
||
].join('\n')),
|
||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<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(
|
||
scrollable: true,
|
||
title: Text(tr('정렬 기준')),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Wrap(spacing: 6, children: [
|
||
for (final (v, label) in [
|
||
('name', tr('이름')),
|
||
('date', tr('바뀐 날')),
|
||
('size', tr('크기')),
|
||
('type', tr('종류 (확장자)')),
|
||
])
|
||
ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)),
|
||
]),
|
||
SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)),
|
||
]),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
await c.updateSettings((x) => x
|
||
..explorerSort = by
|
||
..explorerSortDesc = desc);
|
||
for (final pane in _panes) {
|
||
for (final k in pane.cache.keys.toList()) {
|
||
pane.cache[k] = _sorted(pane.cache[k]!);
|
||
}
|
||
pane.changed();
|
||
}
|
||
}
|
||
|
||
Future<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(
|
||
scrollable: true,
|
||
title: Text(tr('창 배치')),
|
||
content: SizedBox(
|
||
width: 420,
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
group(tr('창'), layout, [
|
||
('dual', tr('두 창')),
|
||
('split', tr('폴더 + 파일 목록')),
|
||
('single', tr('한 창')),
|
||
], (v) => layout = v),
|
||
if (layout == 'split')
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6, left: 4),
|
||
child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'),
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
if (layout != 'single')
|
||
group(tr('두 창 배치'), orient, [
|
||
('auto', tr('화면 모양 따라')),
|
||
('side', tr('좌우')),
|
||
('stacked', tr('위아래')),
|
||
], (v) => orient = v),
|
||
group(tr('기능 버튼 줄'), bar, [
|
||
('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')),
|
||
('edge', tr('끝 (오른쪽 · 아래)')),
|
||
('hidden', tr('숨김')),
|
||
], (v) => bar = v),
|
||
if (bar == 'hidden')
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8, left: 4),
|
||
child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'),
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
await c.updateSettings((x) => x
|
||
..explorerLayout = layout
|
||
..explorerOrientation = orient
|
||
..explorerToolbar = bar);
|
||
if (layout == 'single') setState(() => _active = 0);
|
||
// 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게
|
||
for (final pane in _panes) {
|
||
_reveal(pane, pane.current);
|
||
}
|
||
}
|
||
|
||
Future<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: SwipeNav(
|
||
current: widget.rsync ? 'rsync' : 'explorer',
|
||
child: ListenableBuilder(
|
||
listenable: c,
|
||
builder: (context, _) {
|
||
_dropRemovedServers();
|
||
return Column(children: [
|
||
_topBar(),
|
||
Expanded(
|
||
child: Stack(children: [
|
||
Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())),
|
||
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
|
||
Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()),
|
||
]),
|
||
),
|
||
]);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _topBar() => AppTopBar(
|
||
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
|
||
actions: AppActions(c: c),
|
||
middle: Row(children: [
|
||
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(_ready ? vDisplay(_pane.current) : '',
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
|
||
),
|
||
PopupMenuButton<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))]),
|
||
),
|
||
],
|
||
),
|
||
]),
|
||
);
|
||
|
||
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
|
||
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),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|