JJ_MKVMaker/lib/ui/explorer_page.dart

458 lines
21 KiB
Dart

export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc;
export 'explorer/explorer_buttons.dart' show ExplorerButton;
export 'explorer/transfer_panel.dart' show TransferProgressChip;
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app/app_controller.dart';
import '../app/explorer_device.dart';
import '../app/copy_center.dart';
import '../core/file_ops.dart';
import 'app_actions.dart';
import '../core/thumb_cache.dart' show thumbMemoryForgetters;
import 'explorer_drag.dart';
import '../core/wifi_share.dart' show WifiShare;
import '../app/volumes.dart';
import 'explorer/style_ask.dart';
import 'explorer/explorer_controller.dart';
import 'explorer/explorer_host.dart';
import 'explorer/explorer_pane.dart';
import 'explorer/file_thumbs.dart';
import 'explorer/explorer_select.dart';
import 'explorer/pane_header.dart';
import 'explorer/pane_view.dart';
import 'explorer/explorer_buttons.dart';
import 'explorer/explorer_flows.dart';
import 'explorer/explorer_bars.dart' show ExplorerTopBar;
import 'explorer/explorer_body.dart';
import 'explorer/explorer_layout.dart';
import 'explorer/key_flow.dart';
import 'explorer/tools_flow.dart' show WifiShareBar;
import 'explorer/access_flow.dart';
import 'explorer/pane_notices.dart';
import 'explorer/item_menu.dart';
import 'explorer/transfer_panel.dart';
import 'explorer/marks_bar.dart';
import 'explorer/server_flows.dart';
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
///
/// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더".
/// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램),
/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …).
/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로.
/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴).
///
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
class ExplorerPage extends StatefulWidget {
final AppController c;
final bool rsync;
/// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게
final DateTime Function() clock;
/// 저장 장치 · 드라이브 · Android 에서만 하는 것 (앱은 이 기기 그대로, 시험은 시험 폴더)
final ExplorerDevice device;
const ExplorerPage(
{super.key, required this.c, this.rsync = false, this.clock = DateTime.now, this.device = const RealExplorerDevice()});
/// 이번 실행에서 시스템 폴더 · 루트 경고를 보였는지 ([ExplorerAccess])
static bool get shizukuWarnedThisRun => ExplorerAccess.shizukuWarned;
static set shizukuWarnedThisRun(bool v) => ExplorerAccess.shizukuWarned = v;
static bool get rootWarnedThisRun => ExplorerAccess.rootWarned;
static set rootWarnedThisRun(bool v) => ExplorerAccess.rootWarned = v;
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);
/// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등
static Future<void> openAt(NavigatorState nav, {required AppController c, required String dir}) {
goToRequest.value = dir;
return _show(nav, c, rsync: false);
}
/// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다)
static final goToRequest = ValueNotifier<String?>(null);
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();
}
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, ExplorerPaneParts, ExplorerCommands {
AppController get c => widget.c;
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this, fs: widget.device.fs)..addListener(_onPane);
@override
bool get alive => mounted;
@override
void snack(String text) => _snack(text);
@override
void reveal(ExplorerPane pane, String path) => _layout.reveal(pane, path);
@override
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
/// 창 배치 규칙 · 스크롤 (E5d-6)
late final _layout = ExplorerLayout(_ctl, context: () => context);
/// 키보드 · 마우스 (E5d-6)
late final _input = ExplorerInput(_ctl, _sel, _flows, _buttons, dual: () => _layout.dual);
@override
void initState() {
// 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나)
thumbMemoryForgetters
..add(VideoThumbs.forget)
..add(BigVideoThumbs.forget);
super.initState();
if (widget.rsync) {
ExplorerPage._openRsync++;
} else {
ExplorerPage._open++;
}
for (final pane in _ctl.panes) {
pane.addListener(_onPane);
}
_resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true)));
_hiddenSeen = c.settings.explorer.hiddenItems;
_kindSeen = c.settings.explorer.sortByKind;
c.addListener(_onSettings);
_init();
// E6: 예전 판이 저장한 X-plore 모양이면 한 번 묻는다 (Windows - Rsync 화면은 묻지 않음)
if (!widget.rsync) afterFrame(() => mounted ? unawaited(askExplorerStyleOnce(context, c)) : null);
}
/// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다
late final AppLifecycleListener _resumeReload;
@override
void dispose() {
c.removeListener(_onSettings);
_input.dispose();
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
ExplorerPage._open--;
ExplorerPage.goToRequest.removeListener(_onGoToRequest);
}
_flows.dispose();
_sel.dispose();
_ctl.dispose();
_access.dispose();
_resumeReload.dispose();
super.dispose();
}
void _onPane() {
if (mounted) setState(() {});
}
Future<void> _init() async {
_ctl.local = await widget.device.fs.volumes();
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_access.checkAccess());
final saved = widget.rsync ? c.settings.copy.rsyncPaths : c.settings.explorer.paths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait(
[for (var i = 0; i < 2; i++) i < saved.length ? reachableAsync(saved[i]) : Future.value(false)]);
// U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을
// 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로
final wants = [
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _ctl.local[i < _ctl.local.length ? i : 0].$1,
];
// 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신)
final asked = widget.rsync ? null : ExplorerPage.goToRequest.value;
if (asked != null) {
ExplorerPage.goToRequest.value = null;
wants[0] = asked;
}
for (var i = 0; i < 2; i++) {
_ctl.panes[i]
..root = _ctl.volumeOf(wants[i])
..current = wants[i]
// 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게
..focused = null;
}
if (!mounted) return;
setState(() => _ctl.ready = true);
// (이미 열린 화면에 들어온 요청은 그 화면에서)
if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest);
// 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다
final center = CopyCenter.peekOf(c);
if (center != null) {
for (final job in center.jobs.values.where((j) => !j.finished)) {
_flows.jobs.adopt(job);
}
}
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
if (Platform.isWindows) {
_ctl.drives.classify([for (final (path, _) in _ctl.local) path]);
for (final (path, _) in _ctl.local) {
unawaited(_access.probeDrive(path));
}
}
for (var i = 0; i < 2; i++) {
final pane = _ctl.panes[i];
unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _layout.reveal(pane, pane.current)));
}
}
/// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게
List<String>? _hiddenSeen;
/// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다
bool? _kindSeen;
void _onSettings() {
if (!mounted) return;
if (_kindSeen != null && _kindSeen != c.settings.explorer.sortByKind) {
for (final pane in _ctl.panes) {
for (final k in pane.cache.keys.toList()) {
pane.cache[k] = _ctl.sorted(pane.cache[k]!, k);
}
pane.changed();
}
}
_kindSeen = c.settings.explorer.sortByKind;
final now = c.settings.explorer.hiddenItems;
if (identical(now, _hiddenSeen)) return;
_hiddenSeen = now;
setState(() {});
for (final q in _ctl.panes) {
q.changed();
}
}
// ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ─────────
@override
double get rowHeight => _layout.rowHeight();
@override
double get listTextScale => _layout.listTextScale;
@override
bool get narrowSplit => _layout.narrowSplit;
@override
bool get alwaysMarks => _layout.alwaysMarks;
@override
Widget frame(int i, List<Widget> children) =>
ExplorerPaneFrame(ctl: _ctl, index: i, onPointer: _input.mouseTravel, onDrop: onDrop, children: children);
@override
Future<(int, int)?> diskSpace(String path) => widget.device.diskSpace(path);
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: _ctl,
servers: _servers,
pane: pane,
android: widget.device.androidPaths,
onBookmarks: () => _flows.dialogs.openBookmarks(context, pane),
onOpenFilter: () => _input.openFilter(pane),
onCloseFilter: () => _input.closeFilter(pane),
onOpenRoot: () => _access.openRoot(context, pane),
onOpenShizuku: () => _access.openShizuku(context, pane),
onReconnect: (path) => _access.reconnect(pane, path),
);
@override
List<Widget?> notices(ExplorerPane pane, String dir) => _notices.of(context, pane, dir);
@override
Widget marksBar(ExplorerPane pane, {String? listDir}) => ExplorerMarksBar(sel: _sel, pane: pane, listDir: listDir);
@override
void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _itemMenu.show(context, pane, e, at);
@override
List<String> dragPaths(ExplorerPane pane, FileEntry e) => _flows.drops.dragPaths(pane, e);
@override
Future<void> onDrop(DragLoad load, int to, String dest) => _flows.drops.drop(context, load, to, dest);
@override
void closeFilter(ExplorerPane pane) => _input.closeFilter(pane);
@override
void filterChanged() => setState(() {});
/// 기능 버튼 → 할 일 (E5d-4)
late final _buttons = ExplorerButtons(_ctl, _sel, this, _flows);
/// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6)
late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel,
androidTrash: () => widget.device.androidTrash,
otherDirOf: (pane) => _layout.dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null,
otherDir: () => _layout.other?.current,
twoPanes: () => _layout.dual || _layout.split)
..jobs.addListener(_onPane)
..transfers.addListener(_onPane)
..search.addListener(_onPane);
@override
String? get otherDir => _layout.other?.current;
/// 줄 메뉴 (E5d-5)
late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, _flows, nomedia: () => widget.device.nomedia);
@override
bool get dual => _layout.dual;
/// 권한 · 시스템 폴더 · 다시 연결 · 목록 위 안내 (E5d-6)
late final _access = ExplorerAccess(_ctl, probe: widget.device.fs.probe)..addListener(_onPane);
late final _notices = ExplorerNotices(_ctl, _access, _servers);
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
late final _servers = ExplorerServers(_ctl);
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _flows.opener.open(context, path, pane: pane))
..addListener(_onPane);
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
void _onGoToRequest() {
final dir = ExplorerPage.goToRequest.value;
if (dir == null || !_ctl.ready || !mounted) return;
ExplorerPage.goToRequest.value = null;
unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _layout.reveal(_ctl.pane, dir)));
}
// ───────── 화면 ─────────
/// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님)
bool get _backHasWork =>
_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root));
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
Future<void> _onBack() async {
if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
_sel.setSelecting(false);
return;
}
if (!samePath(_ctl.pane.current, _ctl.pane.root)) {
await _buttons.press(ExplorerButton.up);
return;
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_backHasWork,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_onBack());
},
child: _buildPage(context),
);
}
Widget _buildPage(BuildContext context) {
// P0 (15 지적 10/17): 글자 칸 (빠른 필터 · 경로 등) 에 치는 중이면 파일 단축키를 쓰지 않는다 - 예전에는 CallbackShortcuts 가
// 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다
final bindings = <ShortcutActivator, VoidCallback>{
// 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키)
..._input.bindings(context),
// 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다
const SingleActivator(LogicalKeyboardKey.escape): () {
// 76: 열린 빠른 필터부터 닫는다
if (_ctl.panes[_ctl.active].filterOpen) {
_input.closeFilter(_ctl.panes[_ctl.active]);
} else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
_sel.setSelecting(false);
}
},
};
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, ev) => _input.shortcut(bindings, ev),
child: Focus(
focusNode: _input.focus,
autofocus: true,
// 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼)
onKeyEvent: (_, ev) => _input.typeToFilter(ev),
child: Listener(
// 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [ExplorerPaneFrame])
onPointerDown: (ev) => _input.mouseTravel(_ctl.active, ev),
child: Scaffold(
body: SwipeNav(
current: widget.rsync ? 'rsync' : 'explorer',
child: ListenableBuilder(
listenable: c,
builder: (context, _) {
_ctl.dropRemovedServers();
// 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다
_ctl.sizes.retainAuto([for (final x in _ctl.panes) x.current]);
final body = ExplorerPanesBody(ctl: _ctl, sel: _sel, layout: _layout, parts: this, buttons: _buttons, flows: _flows);
return Column(children: [
ExplorerTopBar(ctl: _ctl, buttons: _buttons),
?_flows.search.backBar(context),
// 89: Wi-Fi 공유가 켜져 있는 동안
if (!widget.rsync)
ValueListenableBuilder<WifiShare?>(
valueListenable: WifiShare.current,
builder: (_, s, _) => s == null ? const SizedBox.shrink() : WifiShareBar(c: c, share: s),
),
Expanded(
child: Stack(children: [
// X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만)
Positioned.fill(
child: !_ctl.ready
? const Center(child: CircularProgressIndicator())
: _layout.swipePanes
? GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
if (v.abs() >= 500) _layout.switchPane(v < 0 ? 1 : -1);
},
child: body)
: body),
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _flows.jobs.list, rsync: widget.rsync)),
]),
),
]);
},
),
),
),
),
),
);
}
}