JJ_MKVMaker/lib/ui/explorer/explorer_controller.dart

458 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

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

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../../app/app_controller.dart';
import '../../app/archive_browse.dart';
import '../../app/volumes.dart' as vol;
import '../../app/volumes.dart' show DriveStatus;
import '../../core/app_hidden.dart';
import '../../core/explorer/dir_loader.dart';
import '../../core/explorer/tree_rows.dart';
import '../../core/file_ops.dart';
import '../../core/folder_size.dart';
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
import '../../core/secret_gate.dart';
import '../../core/shizuku.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../archive_ui.dart' show bundledSevenZip;
import '../explorer_look.dart';
import '../file_error.dart';
import 'explorer_host.dart';
import 'explorer_pane.dart';
import '../../app/local_fs.dart';
/// 약속 (E5d-1): 탐색기 화면 하나의 상태와 화면 밖의 일 - 창 두 개 · 지금 창 · 저장 장치 · 드라이브 · 폴더 크기 · 압축 ·
/// 목록에서 숨기기 · 읽기 · 정렬 · 이동 · 뒤로/앞으로 · 기억 · 새로 고침.
/// 일은 이미 있는 모듈에 맡기고 묶어 부르기만 한다 (PaneState · NavHistory · dir_loader · tree_rows · volumes ·
/// archive_browse · app_hidden · folder_size). 화면에 부탁할 것 (알림 · 스크롤 · 다음 그리기) 은 [ExplorerHost] 로.
/// 바뀌면 [notifyListeners] (화면이 다시 그림). 오류는 던지지 않고 창의 오류 · 알림으로.
class ExplorerController extends ChangeNotifier {
ExplorerController(this.c, {required this.rsync, required this.host, this.fs = const RealLocalFs()}) {
sizes.addListener(_onSizes);
drives.addListener(notifyListeners);
}
final AppController c;
final bool rsync;
final ExplorerHost host;
/// 폴더 목록을 읽는 곳 (M2 - 처음 값 = 이 기기)
final LocalFs fs;
late final List<ExplorerPane> panes = [ExplorerPane('', rsync: rsync), ExplorerPane('', rsync: rsync)];
int active = 0;
bool ready = false;
ExplorerPane get pane => panes[active];
/// 이 기기의 저장 장치: (경로, 이름)
List<(String, String)> local = [];
/// 165: 드라이브마다 따로 뒤에서 확인한 상태
final drives = DriveStatus();
/// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억)
final sizes = FolderSizes();
/// X-plore 비교 f: 압축을 폴더처럼 - 잊으면 창들의 그 안 목록도 지운다
late final arc = ArchiveBrowser(bundledSevenZip, onForget: (a) {
for (final q in panes) {
q.cache.removeWhere((k, _) => samePath(k, a) || isSameOrInside(k, a));
}
});
/// X-plore 숨기기: 목록에서 숨긴 것 (앱 설정의 목록 - 파일은 그대로)
final hidden = HiddenList();
/// X-plore 숨기기: [숨긴 항목 n개 보이기] (이번 실행 동안, 모든 창에)
bool showAppHidden = false;
void update() => notifyListeners();
@override
void dispose() {
sizes.dispose();
drives.dispose();
for (final q in panes) {
q.dispose();
}
super.dispose();
}
// ───────── 저장 장치 ─────────
/// 저장 장치 + 서버들 (WebDAV · SFTP · SMB · FTP · 시스템 폴더 · 루트): (경로, 이름)
List<(String, String)> get volumes => [
...local,
for (final s in c.settings.remote.webdavServers) ('$davScheme${s.id}/', s.label),
for (final s in c.settings.remote.sftpServers) ('$sftpScheme${s.id}/', s.label),
for (final s in c.settings.remote.smbServers) ('$smbScheme${s.id}/', s.label),
// l: 평문 FTP 는 이름 앞에 ⚠
for (final s in c.settings.remote.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'),
if (c.settings.tools.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
];
String volumeOf(String path) => vol.volumeOf(path, local);
/// 화면에 보일 경로 (WebDAV 는 "☁ 서버 › 폴더", Android 는 저장 장치 이름부터)
String displayPath(String path) {
if (isDav(path)) {
final d = DavPath.parse(path);
return ['${d.mark} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › ');
}
if (!Platform.isAndroid) return path;
String? best;
var label = '';
for (final (v, l) in local) {
if ((samePath(v, path) || p.isWithin(v, path)) && (best == null || v.length > best.length)) {
best = v;
label = l;
}
}
if (best == null) return path;
final rest = p.relative(path, from: best);
return [label, if (rest != '.') ...p.split(rest)].join(' › ');
}
/// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로
void dropRemovedServers() {
if (!ready) return;
for (final q in panes) {
if (isDav(q.root) && !DavPath.parse(q.root).serverKnown) {
q.sel.clear();
q.cache.removeWhere((k, _) => isDav(k));
q.expanded.removeWhere(isDav);
q.root = local.first.$1; // 다시 그릴 때 또 하지 않게
host.afterFrame(() {
if (host.alive) unawaited(goTo(q, local.first.$1));
});
}
}
}
// ───────── 폴더 크기 (80) ─────────
void _onSizes() {
if (!host.alive) return;
for (final q in panes) {
for (final k in q.cache.keys.toList()) {
if (sortOf(k).$1 == 'size') q.cache[k] = sorted(q.cache[k]!, k);
}
}
notifyListeners();
}
/// 폴더 크기 글 (모르면 null). 설정이 자동이면 계산을 맡긴다 - 지금 보는 폴더 바로 안의 폴더만, WebDAV 는 하지 않음
String? dirSize(ExplorerPane q, FileEntry e) {
final s = sizes.of(e.path);
if (s != null) return s.label;
if (sizes.busy(e.path)) return '…';
final mode = c.settings.explorer.folderSize;
if ((mode == 'always' || (mode == 'sort' && sortOf(q.current).$1 == 'size')) &&
samePath(vDirname(e.path), q.current) &&
!isDav(e.path)) {
final forDir = q.current;
host.afterFrame(() {
if (host.alive) sizes.request(e.path, forDir: forDir);
});
return '…';
}
return null;
}
// ───────── 목록 읽기 ─────────
Future<void> load(ExplorerPane q, String dir, {bool force = false}) async {
final r = await loadDir(q, dir,
force: force,
showHidden: c.settings.explorer.showHidden,
sort: sorted,
inside: _insideArchiveList,
osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null,
onFresh: sizes.forget,
list: fs.list);
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로
if (r.vanishedUpTo case final up?) {
host.afterFrame(() {
if (!host.alive) return;
host.snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다'));
unawaited(goTo(q, up, fresh: true));
});
}
}
Future<List<FileEntry>?> _insideArchiveList(String dir, bool force) async {
final a = arc.archiveOf(dir);
if (a == null) return null;
if (force && !await readArchive(a)) return const [];
return arc.contains(a) ? arc.list(dir) : null;
}
/// 압축의 목록을 읽어 둔다 (못 읽으면 까닭을 알림). 읽었으면 true
Future<bool> readArchive(String path) async {
switch (await arc.read(path)) {
case ArchiveRead.ok:
return true;
case ArchiveRead.gone:
return false;
case ArchiveRead.noTool:
host.snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)'));
case ArchiveRead.password:
host.snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요'));
case ArchiveRead.failed:
host.snack(trf('압축을 읽지 못했습니다: {0}', [arc.lastError]));
}
return false;
}
Future<void> refresh(ExplorerPane q, Iterable<String> dirs) async {
for (final d in {...dirs}) {
if (q.cache.containsKey(d)) await load(q, d, force: true);
}
}
Future<void> refreshAll(Iterable<String> dirs) async {
for (final q in panes) {
await refresh(q, dirs);
}
}
/// 복사 · 이동 뒤: 대상 · 원본 폴더와 그 안의 읽어 둔 것을 새로 (없어진 것은 잊음, 자리 · 스크롤 그대로)
Future<void> refreshAfterTransfer(List<String> sources, String dest) async {
for (final x in panes) {
final dirs = {
dest,
...sources.map(vDirname),
for (final k in x.cache.keys)
if (isSameOrInside(k, dest) || sources.any((s) => isSameOrInside(k, s))) k,
};
for (final d in dirs) {
if (!host.alive) return;
if (!x.cache.containsKey(d)) continue;
if (!await vExists(d)) {
x.cache.remove(d);
x.expanded.remove(d);
continue;
}
await load(x, d, force: true);
}
}
}
/// 열려 있는 (보이는) 폴더를 두 창 모두 다시 읽는다 ([localOnly]: WebDAV 등은 빼고)
bool _reloading = false;
Future<void> reloadOpen({bool localOnly = false}) async {
if (_reloading) return;
_reloading = true;
try {
for (final x in panes) {
final open = {...x.expanded, x.current};
final had = {...x.cache.keys};
for (final d in open) {
if (!host.alive) return;
if (localOnly && isDav(d)) continue;
await load(x, d, force: true);
}
// 빈 목록 경합 (10/21): 다시 읽는 동안 사용자가 펼친 폴더는 시작할 때 찍은 [open] 에 없다 - 그것까지 지우면 펼친 폴더가 비어 보였다.
// 시작할 때 읽어 둔 것 중, 끝날 때 열려 있지 않은 것만 잊는다
final still = {...x.expanded, x.current};
x.cache.removeWhere((k, _) => had.contains(k) && !still.contains(k));
x.changed();
}
} finally {
_reloading = false;
}
}
// ───────── 정렬 (84) ─────────
List<FileEntry> sorted(List<FileEntry> list, String dir) {
final (sortBy, desc) = sortOf(dir, list: list);
final by = SortBy.values.firstWhere((s) => s.name == sortBy, orElse: () => SortBy.name);
return sortEntries(list, by, descending: desc, dirSize: (d) => sizes.of(d.path)?.bytes);
}
static String _sortKey(String dir) => isDav(dir) ? vNorm(dir) : (Platform.isWindows ? dir.toLowerCase() : dir);
/// [dir] 의 정렬 (기준, 거꾸로) - 폴더마다 기억한 것 → 종류별 기본 정렬 → 전체 정렬
(String, bool) sortOf(String dir, {List<FileEntry>? list}) {
final s = c.settings;
if (s.explorer.sortPerFolder || s.explorer.sortByKind) {
final v = s.explorer.folderSorts[_sortKey(dir)];
if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1'));
}
if (s.explorer.sortByKind) {
final k = _kindSort(dir, list: list);
if (k != null) return k;
}
return (s.explorer.sort, s.explorer.sortDesc);
}
/// X-plore 비교 5-1 ①: 파일의 반 넘게가 사진 · 동영상이면 날짜 (새것 먼저), 음악이면 이름, 그 밖은 null
(String, bool)? _kindSort(String dir, {List<FileEntry>? list}) {
if (list == null) {
for (final q in panes) {
list = q.cache[dir];
if (list != null) break;
}
}
final files = [for (final e in list ?? const <FileEntry>[]) if (!e.isDir) e];
if (files.isEmpty) return null;
final media = files.where((e) => c.settings.reader.imageExts.contains(e.ext) || isVideoFile(e.path)).length;
if (media * 2 > files.length) return ('date', true);
if (files.where((e) => isAudioFile(e.path)).length * 2 > files.length) return ('name', false);
return null;
}
/// 정렬을 바꾼다 - "폴더마다 기억" (또는 종류별 정렬이 쓰이는 폴더) 이면 [dir] 에만, 아니면 전체. 모든 창을 다시 줄 세운다
Future<void> setSort(String dir, String by, bool desc) async {
final kindHere = c.settings.explorer.sortByKind && _kindSort(dir) != null;
await c.updateSettings((x) {
if (x.explorer.sortPerFolder || kindHere) {
final m = Map<String, String>.of(x.explorer.folderSorts)..remove(_sortKey(dir));
m[_sortKey(dir)] = '$by|${desc ? 1 : 0}';
while (m.length > 300) {
m.remove(m.keys.first);
}
x.explorer.folderSorts = m;
} else {
x
..explorer.sort = by
..explorer.sortDesc = desc;
}
});
resortAll();
}
/// 읽어 둔 목록을 모두 다시 줄 세운다
void resortAll() {
for (final q in panes) {
for (final k in q.cache.keys.toList()) {
q.cache[k] = sorted(q.cache[k]!, k);
}
q.changed();
}
}
/// 열 머리를 누름 - 같은 열이면 거꾸로, 다른 열이면 그 열로 (오름차순)
Future<void> sortByColumn(ExplorerPane q, String by) async {
active = panes.indexOf(q);
notifyListeners();
final (now, desc) = sortOf(q.current);
await setSort(q.current, by, now == by ? !desc : false);
}
// ───────── 이동 ─────────
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다. [fresh] 면 그 폴더를 새로 읽고,
/// [focus] 일 때만 그 폴더를 고른 항목으로 (48 보충). [remember] 면 마지막 폴더 · 내역에 남긴다
Future<void> goTo(ExplorerPane q, String dir, {bool remember = true, bool fresh = false, bool focus = false}) async {
// 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다
if (remember && isDav(dir)) await SecretGate.pass(force: true);
if (!host.alive) return;
q.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, q.root) || vDirname(d) == d) break;
d = vDirname(d);
}
for (final (k, x) in chain.indexed) {
q.expanded.add(x);
await load(q, x, force: fresh && k == chain.length - 1);
// 122: 읽어 둔 목록에 다음 폴더가 없으면 (앱 밖에서 새로 만듦) 새로 읽어야 그 폴더까지 펼쳐진다
if (k < chain.length - 1 && !(q.cache[x] ?? const <FileEntry>[]).any((e) => samePath(e.path, chain[k + 1]))) {
await load(q, x, force: true);
}
}
q.current = dir;
if (focus) q.focused = dir;
q.changed();
if (q.listScroll.hasClients) q.listScroll.jumpTo(0);
host.reveal(q, dir);
if (remember) this.remember(q, dir);
}
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) - 내역 규칙은 NavHistory. 갈 수 없는 폴더는 내역에서 빼고 알림
Future<void> travel(ExplorerPane q, {required bool forward}) async {
final to = q.nav.take(forward: forward);
if (to == null) return;
try {
active = panes.indexOf(q);
notifyListeners();
bool ok;
try {
ok = await vIsDir(to);
} catch (_) {
ok = false;
}
if (!host.alive) return;
if (!ok) {
for (final x in panes) {
x.expanded.removeWhere((d) => samePath(d, to) || d.toLowerCase().startsWith('${to.toLowerCase()}${isDav(to) ? '/' : p.separator}'));
}
await refreshAll([vDirname(to)]);
if (!host.alive) return;
notifyListeners();
host.snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [displayPath(to)]));
return;
}
q.nav.arrive(q.current, forward: forward);
await goTo(q, to);
} finally {
q.nav.finish();
}
if (host.alive) notifyListeners();
}
/// 마지막 폴더 · 내역 저장
void remember(ExplorerPane q, String dir) {
final i = panes.indexOf(q);
final paths = [...panes.map((x) => x.current)];
if (rsync) {
unawaited(c.updateSettings((s) => s.copy.rsyncPaths = paths));
} else {
final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s
..explorer.paths = paths
..explorer.history = hist));
}
if (i >= 0) active = i;
}
// ───────── 줄 목록 · 목록에서 숨기기 ─────────
ExplorerStyle get look => ExplorerStyle.of(c.settings.explorer.style);
bool get rootsInTree => !rsync && look == ExplorerStyle.xplore && c.settings.explorer.rootsInTree;
List<TreeRow> rows(ExplorerPane q, {bool foldersOnly = false}) => buildTreeRows(q,
foldersOnly: foldersOnly,
roots: rootsInTree ? [for (final (path, _) in volumes) path] : const [],
favorites: rootsInTree ? [for (final b in c.settings.general.folderBookmarks) b.path] : const [],
isHidden: appHidden,
showHidden: showAppHidden,
browsable: arc.contains);
bool appHidden(String path) => hidden.isHidden(c.settings.explorer.hiddenItems, path);
/// 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18)
Future<void> followHidden({Iterable<(String, String)> moved = const [], Iterable<String> gone = const []}) async {
final out = await HiddenList.follow(c.settings.explorer.hiddenItems, moved: moved, gone: gone);
if (out != null) await c.updateSettings((s) => s.explorer.hiddenItems = out);
}
void toggleAppHidden() {
showAppHidden = !showAppHidden;
notifyListeners();
for (final q in panes) {
q.changed();
}
}
}