1835 lines
72 KiB
Dart
1835 lines
72 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/transfer_job.dart';
|
||
import '../core/sync_tools.dart';
|
||
import '../core/file_ops.dart';
|
||
import '../core/playlist.dart' show isVideoFile;
|
||
import '../platform/android/android_storage.dart';
|
||
import 'app_actions.dart';
|
||
import 'explorer_look.dart';
|
||
import 'monitor_page.dart';
|
||
import 'player_page.dart';
|
||
import 'rsync_setup.dart';
|
||
import 'theme.dart';
|
||
import '../l10n/tr.dart';
|
||
|
||
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
|
||
///
|
||
/// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더".
|
||
/// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램),
|
||
/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …).
|
||
/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로.
|
||
/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴).
|
||
class ExplorerPage extends StatefulWidget {
|
||
final AppController c;
|
||
const ExplorerPage({super.key, required this.c});
|
||
|
||
static const routeName = 'explorer';
|
||
static int _open = 0;
|
||
|
||
/// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로)
|
||
static Future<void> open(NavigatorState nav, {required AppController c}) async {
|
||
if (_open > 0) {
|
||
var found = false;
|
||
nav.popUntil((r) {
|
||
if (r.settings.name == routeName) found = true;
|
||
return found || r.isFirst;
|
||
});
|
||
if (found) return;
|
||
}
|
||
await nav.push(MaterialPageRoute<void>(
|
||
settings: const RouteSettings(name: routeName),
|
||
builder: (_) => ExplorerPage(c: c),
|
||
));
|
||
}
|
||
|
||
@override
|
||
State<ExplorerPage> createState() => _ExplorerPageState();
|
||
}
|
||
|
||
/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다.
|
||
enum ExplorerButton {
|
||
up(Icons.arrow_upward, '상위 폴더'),
|
||
refresh(Icons.refresh, '새로 고침'),
|
||
select(Icons.checklist, '선택'),
|
||
monitor(Icons.monitor_heart_outlined, '모니터링'),
|
||
play(Icons.play_circle_outline, '재생'),
|
||
addMkv(Icons.playlist_add, 'MKV 목록에 추가'),
|
||
newFolder(Icons.create_new_folder_outlined, '새 폴더'),
|
||
rename(Icons.drive_file_rename_outline, '이름 변경'),
|
||
copy(Icons.copy_outlined, '복사'),
|
||
move(Icons.drive_file_move_outline, '이동'),
|
||
paste(Icons.content_paste, '붙여넣기'),
|
||
delete(Icons.delete_outline, '삭제'),
|
||
search(Icons.search, '찾기'),
|
||
sort(Icons.sort, '정렬 기준'),
|
||
hidden(Icons.visibility_outlined, '숨은 항목 표시'),
|
||
history(Icons.history, '내역'),
|
||
layout(Icons.view_quilt_outlined, '창 배치'),
|
||
buttons(Icons.tune, '버튼 구성');
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
const ExplorerButton(this.icon, this.label);
|
||
|
||
static List<ExplorerButton> fromSettings(List<String> names) {
|
||
final out = [
|
||
for (final n in names)
|
||
for (final b in values)
|
||
if (b.name == n) b,
|
||
];
|
||
return out.isEmpty ? values.toList() : out;
|
||
}
|
||
}
|
||
|
||
/// 한 창의 상태: 저장 장치 (맨 위), 펼친 폴더, 읽어 둔 목록, 표시한 항목
|
||
class _Pane extends ChangeNotifier {
|
||
String root;
|
||
String current;
|
||
String? focused;
|
||
final expanded = <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)> _volumes = [];
|
||
late final List<_Pane> _panes = [_Pane(''), _Pane('')];
|
||
int _active = 0;
|
||
bool _ready = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
ExplorerPage._open++;
|
||
for (final pane in _panes) {
|
||
pane.addListener(_onPane);
|
||
}
|
||
_init();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
ExplorerPage._open--;
|
||
for (final pane in _panes) {
|
||
pane.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
void _onPane() {
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
_volumes = await _loadVolumes();
|
||
if (_volumes.isEmpty) _volumes = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
|
||
final saved = c.settings.explorerPaths;
|
||
for (var i = 0; i < 2; i++) {
|
||
final want = i < saved.length && Directory(saved[i]).existsSync() ? saved[i] : _volumes[0].$1;
|
||
await _goTo(_panes[i], want, remember: false);
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _ready = true);
|
||
for (final pane in _panes) {
|
||
_reveal(pane, pane.current); // 목록이 그려진 뒤에
|
||
}
|
||
}
|
||
|
||
Future<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 [('/', '/')];
|
||
}
|
||
|
||
String _volumeOf(String path) {
|
||
String best = _volumes.first.$1;
|
||
var len = -1;
|
||
for (final (v, _) in _volumes) {
|
||
final inside = samePath(v, path) || p.isWithin(v, path) ||
|
||
(Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
|
||
if (inside && v.length > len) {
|
||
best = v;
|
||
len = v.length;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ───────── 목록 읽기 · 이동 ─────────
|
||
|
||
Future<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>[];
|
||
var d = p.normalize(dir);
|
||
while (true) {
|
||
chain.insert(0, d);
|
||
if (samePath(d, pane.root) || p.dirname(d) == d) break;
|
||
d = p.dirname(d);
|
||
}
|
||
for (final x in chain) {
|
||
pane.expanded.add(x);
|
||
await _load(pane, x);
|
||
}
|
||
pane
|
||
..current = dir
|
||
..focused = dir;
|
||
pane.changed();
|
||
if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
|
||
_reveal(pane, dir);
|
||
if (remember) _remember(pane, dir);
|
||
}
|
||
|
||
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
|
||
void _reveal(_Pane pane, String path) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted || !pane.scroll.hasClients) return;
|
||
final i = _rows(pane, foldersOnly: _split).indexWhere((r) => samePath(r.entry.path, path));
|
||
if (i < 0) return;
|
||
final pos = pane.scroll.position;
|
||
final want = (i * _look.rowHeight - pos.viewportDimension / 4).clamp(0.0, pos.maxScrollExtent);
|
||
pane.scroll.jumpTo(want);
|
||
});
|
||
}
|
||
|
||
/// 마지막 폴더 · 내역 저장
|
||
void _remember(_Pane pane, String dir) {
|
||
final i = _panes.indexOf(pane);
|
||
final paths = [..._panes.map((x) => x.current)];
|
||
final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList();
|
||
unawaited(c.updateSettings((s) => s
|
||
..explorerPaths = paths
|
||
..explorerHistory = hist));
|
||
if (i >= 0) _active = i;
|
||
}
|
||
|
||
/// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일)
|
||
ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle);
|
||
|
||
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
|
||
bool get _split => c.settings.explorerLayout == 'split';
|
||
|
||
/// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기)
|
||
bool get _selectMode => c.settings.explorerClick != 'open';
|
||
|
||
/// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
|
||
List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) {
|
||
final out = <_Row>[];
|
||
final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0));
|
||
out.add(_Row(rootEntry, 0, isRoot: true));
|
||
void walk(String dir, int depth) {
|
||
if (!pane.expanded.contains(dir)) return;
|
||
for (final e in pane.cache[dir] ?? const <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 (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) {
|
||
if (!_selecting) setState(() => _selecting = true);
|
||
await _toggleMark(pane, e);
|
||
return;
|
||
}
|
||
if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list);
|
||
pane.focused = e.path;
|
||
if (!list) pane.current = p.dirname(e.path);
|
||
pane.changed();
|
||
}
|
||
|
||
/// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것)
|
||
bool _isMarked(_Pane pane, String path) => pane.marked.any((m) => isSameOrInside(path, m));
|
||
|
||
/// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로)
|
||
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 = p.dirname(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 playFiles(context, c, [path]);
|
||
return;
|
||
}
|
||
final ok = await c.services.shell.openWith(path);
|
||
if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||
}
|
||
|
||
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 dual = c.settings.explorerLayout == 'dual';
|
||
final items = <(String, IconData, String)>[
|
||
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
|
||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
|
||
if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
|
||
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
|
||
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),
|
||
if (video) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
|
||
('select', Icons.check_circle_outline, tr('선택')),
|
||
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
|
||
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
|
||
if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')),
|
||
// 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기
|
||
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
|
||
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
|
||
if (_clip.isNotEmpty)
|
||
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [p.basename(e.isDir ? e.path : p.dirname(e.path)), _clip.length])),
|
||
('delete', Icons.delete_outline, tr('삭제')),
|
||
if (!Platform.isAndroid || e.isDir) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||
('info', Icons.info_outline, tr('정보')),
|
||
];
|
||
final size = MediaQuery.sizeOf(context);
|
||
final pick = await showMenu<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 playFiles(context, c, [e.path], internal: true);
|
||
case 'openWith':
|
||
if (!await c.services.shell.openWith(e.path, choose: true)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||
case 'openDefault':
|
||
if (!await c.services.shell.openWith(e.path)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||
case 'addMkv':
|
||
await _addMkv([e.path]);
|
||
case 'select':
|
||
setState(() => _selecting = true);
|
||
if (!_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 : p.dirname(e.path));
|
||
case 'delete':
|
||
await _delete(pane, [e.path]);
|
||
case 'reveal':
|
||
await c.services.shell.revealFile(e.path);
|
||
case 'info':
|
||
await _info(e);
|
||
}
|
||
}
|
||
|
||
// ───────── 기능 ─────────
|
||
|
||
_Pane get _pane => _panes[_active];
|
||
_Pane? get _other => c.settings.explorerLayout == 'dual' ? _panes[1 - _active] : null;
|
||
|
||
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
|
||
List<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 = p.dirname(pane.current);
|
||
pane.expanded.remove(pane.current);
|
||
await _goTo(pane, up);
|
||
case ExplorerButton.select:
|
||
_setSelecting(!_selecting);
|
||
case ExplorerButton.monitor:
|
||
await MonitorPage.open(context, c);
|
||
case ExplorerButton.refresh:
|
||
pane.cache.clear();
|
||
for (final d in pane.expanded.toList()) {
|
||
await _load(pane, d, force: true);
|
||
}
|
||
case ExplorerButton.play:
|
||
final t = _targets(pane);
|
||
final videos = t.where((x) => !FileSystemEntity.isDirectorySync(x) && isVideoFile(x)).toList();
|
||
if (videos.isNotEmpty) {
|
||
await playFiles(context, c, videos, keepOrder: pane.marked.isNotEmpty);
|
||
} else {
|
||
await _playFolder(t.length == 1 && FileSystemEntity.isDirectorySync(t.first) ? t.first : pane.current);
|
||
}
|
||
case ExplorerButton.addMkv:
|
||
final t = _targets(pane);
|
||
await _addMkv(t.isEmpty ? [pane.current] : t);
|
||
case ExplorerButton.newFolder:
|
||
final name = await _askName(tr('새 폴더'), '');
|
||
if (name == null) return;
|
||
try {
|
||
final made = await FileOps.makeFolder(pane.current, name);
|
||
await _refreshAll([pane.current]);
|
||
pane.focused = made;
|
||
pane.changed();
|
||
} catch (e) {
|
||
_snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
|
||
}
|
||
case ExplorerButton.rename:
|
||
final t = _targets(pane);
|
||
if (t.length != 1) {
|
||
_snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
|
||
return;
|
||
}
|
||
await _rename(pane, t.first);
|
||
case ExplorerButton.copy:
|
||
await _transfer(pane, _targets(pane), move: false);
|
||
case ExplorerButton.move:
|
||
await _transfer(pane, _targets(pane), move: true);
|
||
case ExplorerButton.paste:
|
||
await _paste(pane, pane.current);
|
||
case ExplorerButton.delete:
|
||
await _delete(pane, _targets(pane));
|
||
case ExplorerButton.search:
|
||
await _search(pane);
|
||
case ExplorerButton.sort:
|
||
await _sortDialog();
|
||
case ExplorerButton.hidden:
|
||
await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden);
|
||
for (final x in _panes) {
|
||
x.cache.clear();
|
||
for (final d in x.expanded.toList()) {
|
||
await _load(x, d, force: true);
|
||
}
|
||
}
|
||
_snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.'));
|
||
case ExplorerButton.history:
|
||
await _historyDialog(pane);
|
||
case ExplorerButton.layout:
|
||
await _layoutDialog();
|
||
case ExplorerButton.buttons:
|
||
await _buttonsDialog();
|
||
}
|
||
}
|
||
|
||
Future<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 playFiles(context, c, list);
|
||
}
|
||
|
||
Future<void> _addMkv(List<String> paths) async {
|
||
final videos = <String>[];
|
||
for (final x in paths) {
|
||
if (FileSystemEntity.isDirectorySync(x)) {
|
||
videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
|
||
} else if (isVideoFile(x)) {
|
||
videos.add(x);
|
||
}
|
||
}
|
||
if (videos.isEmpty) {
|
||
_snack(tr('추가할 동영상이 없습니다.'));
|
||
return;
|
||
}
|
||
await c.addVideos(videos, allowOutputFolder: true);
|
||
_snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length]));
|
||
}
|
||
|
||
Future<void> _rename(_Pane pane, String path) async {
|
||
final name = await _askName(tr('이름 변경'), p.basename(path));
|
||
if (name == null || name == p.basename(path)) return;
|
||
try {
|
||
final now = await FileOps.rename(path, name);
|
||
await _refreshAll([p.dirname(path)]);
|
||
for (final x in _panes) {
|
||
if (x.marked.remove(path)) x.marked.add(now);
|
||
}
|
||
pane.focused = now;
|
||
pane.changed();
|
||
} catch (e) {
|
||
_snack(trf('이름을 바꿀 수 없습니다: {0}', [e]));
|
||
}
|
||
}
|
||
|
||
Future<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}" 을(를) 지울까요? 되돌릴 수 없습니다.', [p.basename(paths.first)])}\n${p.dirname(paths.first)}'
|
||
: [
|
||
trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]),
|
||
for (final x in paths.take(8)) '· ${p.basename(x)}',
|
||
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
|
||
{for (final x in paths) p.dirname(x)}.join('\n'),
|
||
].join('\n')),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(tr('삭제')),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
try {
|
||
await FileOps().delete(paths);
|
||
} catch (e) {
|
||
_snack(trf('지우지 못했습니다: {0}', [e]));
|
||
}
|
||
for (final x in _panes) {
|
||
x.marked.removeAll(paths);
|
||
if (_selecting && x.marked.isEmpty) _selecting = false;
|
||
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
|
||
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = p.dirname(paths.first);
|
||
}
|
||
await _refreshAll(paths.map(p.dirname));
|
||
}
|
||
|
||
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
|
||
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
|
||
Future<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 = []);
|
||
}
|
||
|
||
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
|
||
TransferJob? _job;
|
||
|
||
/// 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 (_job != null && !_job!.finished) {
|
||
_snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.'));
|
||
return false;
|
||
}
|
||
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
|
||
final problem = transferProblem(sources, dest, move: move);
|
||
if (problem != null) {
|
||
_snack(problem);
|
||
pane.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound);
|
||
await _refreshAll([dest, ...sources.map(p.dirname)]);
|
||
for (final x in _panes) {
|
||
x.changed();
|
||
}
|
||
return false;
|
||
}
|
||
// 복사 모니터링을 켜면 기억한 같은 복사의 옵션을 쓰고, 처음이면 기억한다
|
||
final center = CopyCenter.of(c);
|
||
final task = c.settings.copyMonitor
|
||
? await center.remember(sources, dest, move: move)
|
||
: center.fresh(sources, dest, move: move);
|
||
final method = CopyMethod.of(task.method);
|
||
if (!mounted) return false;
|
||
final go = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(move ? tr('이동') : tr('복사')),
|
||
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])}'
|
||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
|
||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))),
|
||
],
|
||
),
|
||
);
|
||
if (go != true || !mounted) return false;
|
||
String? rsync;
|
||
if (method == CopyMethod.rsync) {
|
||
rsync = await _ensureRsync();
|
||
if (rsync == null || !mounted) return false;
|
||
}
|
||
final job = await center.start(task, rsyncExe: rsync);
|
||
if (job == null || !mounted) return false;
|
||
setState(() => _job = job);
|
||
await job.done;
|
||
final error = job.error;
|
||
if (!mounted) return false;
|
||
pane.marked.clear();
|
||
if (_selecting) _setSelecting(false);
|
||
await _refreshAll([dest, ...sources.map(p.dirname)]);
|
||
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
|
||
for (final x in _panes) {
|
||
if (samePath(x.current, dest)) {
|
||
x.expanded.add(dest);
|
||
await _load(x, dest, force: true);
|
||
}
|
||
x.changed();
|
||
}
|
||
_snack(error is FileOpCancelled
|
||
? tr('취소했습니다.')
|
||
: error != null
|
||
? trf('끝나지 못했습니다: {0}', [error])
|
||
: trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
|
||
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
|
||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||
if (mounted && _job == job) setState(() => _job = null);
|
||
return error == null;
|
||
}
|
||
|
||
/// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
|
||
Widget _transferPanel() {
|
||
final job = _job;
|
||
return AnimatedSlide(
|
||
offset: job == null ? const Offset(0, 1.2) : Offset.zero,
|
||
duration: const Duration(milliseconds: 250),
|
||
curve: Curves.easeOut,
|
||
child: job == null
|
||
? const SizedBox(height: 0)
|
||
: ListenableBuilder(
|
||
listenable: job,
|
||
builder: (context, _) {
|
||
String pct(double v) => '${(v * 100).round()}%';
|
||
final cur = job.index < job.total ? job.index : job.total - 1;
|
||
final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)];
|
||
final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)];
|
||
final title = job.finished
|
||
? (job.error == null ? tr('완료') : tr('멈춤'))
|
||
: job.move
|
||
? tr('옮기는 중')
|
||
: tr('복사하는 중');
|
||
Widget line(String label, double value, String right) => Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Row(children: [
|
||
SizedBox(
|
||
width: 300,
|
||
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(3),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 8,
|
||
value: job.counting ? null : value,
|
||
// 빈 곳과 찬 곳이 분명히 다르게
|
||
color: JjColors.accent,
|
||
backgroundColor: JjColors.border,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
SizedBox(
|
||
width: 150,
|
||
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
);
|
||
return Material(
|
||
elevation: 12,
|
||
color: JjColors.panel,
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 12, 12),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Row(children: [
|
||
Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent),
|
||
const SizedBox(width: 8),
|
||
Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(job.dest,
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
|
||
]),
|
||
line(
|
||
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
|
||
job.overall,
|
||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
|
||
),
|
||
line(
|
||
job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [job.currentName]),
|
||
job.current,
|
||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<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 && 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 : p.dirname(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((h) => Directory(h).existsSync()).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.values.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.values);
|
||
on
|
||
..clear()
|
||
..addAll(ExplorerButton.values);
|
||
}),
|
||
child: Text(tr('기본값')),
|
||
),
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
final names = [for (final b in order) if (on.contains(b)) b.name];
|
||
await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names);
|
||
}
|
||
|
||
// ───────── 화면 ─────────
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CallbackShortcuts(
|
||
bindings: {
|
||
const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh),
|
||
const SingleActivator(LogicalKeyboardKey.delete): () => _button(ExplorerButton.delete),
|
||
const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename),
|
||
const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up),
|
||
const SingleActivator(LogicalKeyboardKey.tab): () =>
|
||
setState(() => _active = c.settings.explorerLayout == 'dual' ? 1 - _active : 0),
|
||
},
|
||
child: Focus(
|
||
autofocus: true,
|
||
child: Scaffold(
|
||
body: ListenableBuilder(
|
||
listenable: c,
|
||
builder: (context, _) => Column(children: [
|
||
_topBar(),
|
||
Expanded(
|
||
child: Stack(children: [
|
||
Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())),
|
||
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
|
||
Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()),
|
||
]),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _topBar() => Container(
|
||
height: appBarHeight,
|
||
color: JjColors.panel,
|
||
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
|
||
child: Row(children: [
|
||
const AppNavButtons(onExplorerPage: true),
|
||
const SizedBox(width: 8),
|
||
Text(tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(_ready ? _pane.current : '',
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
|
||
),
|
||
PopupMenuButton<ExplorerButton>(
|
||
tooltip: tr('창 배치 · 버튼 구성'),
|
||
icon: const Icon(Icons.more_vert),
|
||
onSelected: _button,
|
||
itemBuilder: (_) => [
|
||
for (final b in [ExplorerButton.layout, ExplorerButton.buttons, ExplorerButton.sort, ExplorerButton.hidden])
|
||
PopupMenuItem(
|
||
value: b,
|
||
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
|
||
),
|
||
],
|
||
),
|
||
AppActions(c: c),
|
||
]),
|
||
);
|
||
|
||
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
|
||
String _shape = '';
|
||
|
||
Widget _body() {
|
||
final s = c.settings;
|
||
final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}';
|
||
if (_ready && shape != _shape) {
|
||
if (_shape.isNotEmpty) {
|
||
for (final pane in _panes) {
|
||
_reveal(pane, pane.current);
|
||
}
|
||
}
|
||
_shape = shape;
|
||
}
|
||
final dual = s.explorerLayout == 'dual';
|
||
final split = _split;
|
||
return LayoutBuilder(builder: (context, box) {
|
||
final side = !(dual || split) ||
|
||
switch (s.explorerOrientation) {
|
||
'side' => true,
|
||
'stacked' => false,
|
||
_ => box.maxWidth >= box.maxHeight,
|
||
};
|
||
final bar = s.explorerToolbar == 'hidden'
|
||
? null
|
||
: _Toolbar(
|
||
// [모니터링] 은 환경 설정 > 복사 모니터링 을 켰을 때만
|
||
buttons: [
|
||
for (final b in ExplorerButton.fromSettings(s.explorerButtons))
|
||
if (b != ExplorerButton.monitor || s.copyMonitor) b,
|
||
],
|
||
vertical: side,
|
||
onPressed: _button,
|
||
enabled: (b) => switch (b) {
|
||
ExplorerButton.paste => _clip.isNotEmpty,
|
||
_ => true,
|
||
},
|
||
selected: (b) =>
|
||
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting),
|
||
);
|
||
final middle = bar != null && s.explorerToolbar == 'middle';
|
||
final edge = bar != null && s.explorerToolbar == 'edge';
|
||
final children = <Widget>[
|
||
if (dual) ...[
|
||
Expanded(child: _paneView(0)),
|
||
if (middle) bar,
|
||
Expanded(child: _paneView(1)),
|
||
if (edge) bar,
|
||
] else if (split) ...[
|
||
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록
|
||
Expanded(flex: 2, child: _paneView(0, foldersOnly: true)),
|
||
if (middle) bar,
|
||
Expanded(flex: 3, child: _listView(_panes[0])),
|
||
if (edge) bar,
|
||
] else ...[
|
||
if (middle) bar,
|
||
Expanded(child: _paneView(0)),
|
||
if (edge) bar,
|
||
],
|
||
];
|
||
return side ? Row(children: children) : Column(children: children);
|
||
});
|
||
}
|
||
|
||
Widget _frame(int i, List<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(p.dirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||
for (final e in entries) _Row(e, 0),
|
||
];
|
||
final look = _look;
|
||
return _frame(0, [
|
||
Container(
|
||
height: 40,
|
||
alignment: Alignment.centerLeft,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Text(dir, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
|
||
),
|
||
const Divider(height: 1),
|
||
if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting),
|
||
Expanded(
|
||
child: pane.loading.contains(dir)
|
||
? const Center(child: CircularProgressIndicator())
|
||
: entries.isEmpty && !up
|
||
? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim)))
|
||
: ListView.builder(
|
||
controller: pane.listScroll,
|
||
itemCount: rows.length,
|
||
itemExtent: look.rowHeight,
|
||
itemBuilder: (_, k) => _rowView(pane, rows[k], list: true),
|
||
),
|
||
),
|
||
if (_selecting) _marksBar(pane, listDir: dir),
|
||
]);
|
||
}
|
||
|
||
/// 창 위쪽: 저장 장치 고르기
|
||
Widget _paneHeader(_Pane pane) => SizedBox(
|
||
height: 40,
|
||
child: ListView(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||
children: [
|
||
for (final (path, label) in _volumes)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
|
||
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
|
||
),
|
||
icon: Icon(Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16),
|
||
label: Text(label),
|
||
onPressed: () {
|
||
setState(() => _active = _panes.indexOf(pane));
|
||
_goTo(pane, path);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
/// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
|
||
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
|
||
final e = row.entry;
|
||
final look = _look;
|
||
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
|
||
final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
|
||
final marked = _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(
|
||
onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null,
|
||
onLongPressStart: real ? (d) => _menu(pane, e, d.globalPosition) : null,
|
||
child: Material(
|
||
color: marked
|
||
? JjColors.accent.withValues(alpha: 0.18)
|
||
: isFocused
|
||
? JjColors.panel
|
||
: Colors.transparent,
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: InkWell(
|
||
onTap: () => _onTap(pane, row, list: list),
|
||
// 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게)
|
||
// 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게)
|
||
onDoubleTap: _selectMode && !_selecting && !e.isDir && !row.isUp
|
||
? () => _onOpen(pane, row, list: list)
|
||
: null,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(left: 6, right: 4),
|
||
child: Row(children: cells),
|
||
),
|
||
),
|
||
),
|
||
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
|
||
if (real && _selecting)
|
||
SizedBox(
|
||
width: 36,
|
||
child: IconButton(
|
||
tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'),
|
||
visualDensity: VisualDensity.compact,
|
||
padding: EdgeInsets.zero,
|
||
// ✔ 모양: 고르지 않은 것은 흐리게, 고른 것은 진하게
|
||
icon: Icon(marked ? Icons.check_circle : Icons.check_circle_outline,
|
||
size: look.rich ? 22 : 18,
|
||
color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)),
|
||
onPressed: () {
|
||
setState(() => _active = _panes.indexOf(pane));
|
||
_toggleMark(pane, e);
|
||
},
|
||
),
|
||
)
|
||
else if (_selecting)
|
||
const SizedBox(width: 36),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
|
||
final look = _look;
|
||
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
|
||
if (isRoot) return Icon(Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
|
||
// X-plore: 동영상 썸네일 · 그림 미리 보기
|
||
if (look.rich && !e.isDir) {
|
||
if (isVideoFile(e.path)) return _Thumb(c: c, entry: e);
|
||
const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
|
||
if (images.contains(e.ext)) {
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(3),
|
||
child: Image.file(File(e.path),
|
||
width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112,
|
||
errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)),
|
||
);
|
||
}
|
||
}
|
||
final (icon, color) = fileIcon(look, e, open: open);
|
||
return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color);
|
||
}
|
||
}
|
||
|
||
String _date(DateTime d) =>
|
||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} '
|
||
'${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||
|
||
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)
|
||
class _Toolbar extends StatelessWidget {
|
||
final List<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(p.dirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
onTap: () => Navigator.pop(context, e),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기')))],
|
||
);
|
||
}
|
||
|
||
// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ─────────
|
||
|
||
/// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게)
|
||
class _Queue {
|
||
final int max;
|
||
int _running = 0;
|
||
final _waiting = Queue<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),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|