JJ_MKVMaker/lib/ui/explorer_page.dart

1360 lines
51 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

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

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../core/file_ops.dart';
import '../core/playlist.dart' show isVideoFile;
import '../platform/android/android_storage.dart';
import 'app_actions.dart';
import 'player_page.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, '새로 고침'),
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, '이동'),
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();
_Pane(this.root) : current = root;
void changed() => notifyListeners();
@override
void dispose() {
scroll.dispose();
super.dispose();
}
}
/// 트리 목록의 한 줄
class _Row {
final FileEntry entry;
final int depth;
final bool isRoot;
const _Row(this.entry, this.depth, {this.isRoot = 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();
_reveal(pane, dir);
if (remember) _remember(pane, dir);
}
/// 목록 줄 높이
static const _rowHeight = 52.0;
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(_Pane pane, String path) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !pane.scroll.hasClients) return;
final i = _rows(pane).indexWhere((r) => samePath(r.entry.path, path));
if (i < 0) return;
final pos = pane.scroll.position;
final want = (i * _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;
}
List<_Row> _rows(_Pane pane) {
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>[]) {
out.add(_Row(e, depth));
if (e.isDir) walk(e.path, depth + 1);
}
}
walk(pane.root, 1);
return out;
}
// ───────── 누르기 ─────────
Future<void> _tap(_Pane pane, FileEntry e) async {
setState(() => _active = _panes.indexOf(pane));
pane.focused = e.path;
if (e.isDir) {
if (pane.expanded.contains(e.path) && samePath(pane.current, e.path)) {
pane.expanded.remove(e.path); // 지금 폴더를 다시 누르면 접기
pane.current = p.dirname(e.path).length < pane.root.length ? pane.root : p.dirname(e.path);
pane.changed();
} else {
pane
..expanded.add(e.path)
..current = e.path;
await _load(pane, e.path);
_remember(pane, e.path);
}
return;
}
pane.current = p.dirname(e.path);
pane.changed();
await _open(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 목록에 추가')),
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')),
('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 '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 '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.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.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);
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) {
_snack(tr('복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).'));
return;
}
if (sources.isEmpty) {
_snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return;
}
final dest = other.current;
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])),
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;
final ops = FileOps();
final progress = ValueNotifier<(String, double)>(('', 0));
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(move ? tr('옮기는 중') : tr('복사하는 중')),
content: ValueListenableBuilder<(String, double)>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(p.basename(v.$1), maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 10),
LinearProgressIndicator(value: v.$2 <= 0 ? null : v.$2),
const SizedBox(height: 6),
Text('${(v.$2 * 100).round()}%', style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
),
actions: [TextButton(onPressed: ops.cancel, child: Text(tr('취소')))],
),
);
Object? error;
var made = <String>[];
try {
void onP(String f, int done, int total) => progress.value = (f, total == 0 ? 0 : done / total);
made = move ? await ops.move(sources, dest, onProgress: onP) : await ops.copy(sources, dest, onProgress: onP);
} on FileOpCancelled {
error = tr('취소했습니다.');
} catch (e) {
error = e;
}
if (mounted) Navigator.of(context).pop();
pane.marked.clear();
await _refreshAll([dest, ...sources.map(p.dirname)]);
other.expanded.add(dest);
other.changed();
_snack(error != null
? trf('끝나지 못했습니다: {0}', [error])
: trf('{0}개 항목을 {1}', [made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
}
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('두 창')), ('single', tr('한 창'))], (v) => layout = v),
if (layout == 'dual')
group(tr('두 창 배치'), orient, [
('auto', tr('화면 모양 따라')),
('side', tr('좌우')),
('stacked', tr('위아래')),
], (v) => orient = v),
group(tr('기능 버튼 줄'), bar, [
('middle', layout == 'dual' ? 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);
}
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: _ready ? _body() : const Center(child: CircularProgressIndicator())),
]),
),
),
),
);
}
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),
]),
);
Widget _body() {
final s = c.settings;
final dual = s.explorerLayout == 'dual';
return LayoutBuilder(builder: (context, box) {
final side = !dual || switch (s.explorerOrientation) {
'side' => true,
'stacked' => false,
_ => box.maxWidth >= box.maxHeight,
};
final bar = s.explorerToolbar == 'hidden' ? null : _Toolbar(
buttons: ExplorerButton.fromSettings(s.explorerButtons),
vertical: side,
onPressed: _button,
enabled: (b) => switch (b) {
ExplorerButton.copy || ExplorerButton.move => dual,
_ => true,
},
selected: (b) => b == ExplorerButton.hidden && s.explorerShowHidden,
);
Widget pane(int i) => Expanded(child: _paneView(i));
final children = <Widget>[
if (dual) ...[
pane(0),
if (bar != null && s.explorerToolbar == 'middle') bar,
pane(1),
if (bar != null && s.explorerToolbar == 'edge') bar,
] else ...[
if (bar != null && s.explorerToolbar == 'middle') bar,
pane(0),
if (bar != null && s.explorerToolbar == 'edge') bar,
],
];
return side ? Row(children: children) : Column(children: children);
});
}
Widget _paneView(int i) {
final pane = _panes[i];
final active = i == _active;
final rows = _rows(pane);
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: [
_paneHeader(pane),
const Divider(height: 1),
Expanded(
child: ListView.builder(
controller: pane.scroll,
itemCount: rows.length,
itemExtent: _rowHeight,
itemBuilder: (_, k) => _rowView(pane, rows[k]),
),
),
if (pane.marked.isNotEmpty)
Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row(children: [
Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)),
const Spacer(),
TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))),
]),
),
]),
),
);
}
/// 창 위쪽: 저장 장치 고르기
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);
},
),
),
],
),
);
Widget _rowView(_Pane pane, _Row row) {
final e = row.entry;
final isCurrent = e.isDir && samePath(pane.current, e.path);
final isFocused = pane.focused != null && samePath(pane.focused!, e.path);
final marked = pane.marked.contains(e.path);
final open = e.isDir && pane.expanded.contains(e.path);
final loading = pane.loading.contains(e.path);
final video = !e.isDir && isVideoFile(e.path);
final name = row.isRoot ? (_volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) : e.name;
return GestureDetector(
onSecondaryTapUp: (d) => _menu(pane, e, d.globalPosition),
onLongPressStart: (d) => _menu(pane, e, d.globalPosition),
child: Material(
color: marked
? JjColors.accent.withValues(alpha: 0.18)
: isFocused
? JjColors.panel
: Colors.transparent,
child: InkWell(
onTap: () => _tap(pane, e),
child: Padding(
padding: EdgeInsets.only(left: 6.0 + row.depth * 16, right: 4),
child: Row(children: [
SizedBox(
width: 18,
child: e.isDir
? (loading
? const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))
: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim))
: null,
),
const SizedBox(width: 4),
SizedBox(width: 56, height: 44, child: Center(child: _icon(e, row.isRoot, open))),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
color: isCurrent ? JjColors.accent : null)),
if (!e.isDir)
Row(children: [
Text('${formatSize(e.size)} · ${_date(e.modified)}',
style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
if (video) ...[
const SizedBox(width: 8),
Flexible(child: _MetaText(c: c, path: e.path)),
],
]),
if (e.isDir && pane.errors[e.path] != null)
Text(tr('읽을 수 없음'), style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
],
),
),
if (!row.isRoot)
IconButton(
tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'),
visualDensity: VisualDensity.compact,
icon: Icon(marked ? Icons.check_circle : Icons.radio_button_unchecked,
size: 20, color: marked ? JjColors.accent : JjColors.textDim),
onPressed: () => setState(() {
_active = _panes.indexOf(pane);
marked ? pane.marked.remove(e.path) : pane.marked.add(e.path);
}),
),
]),
),
),
),
);
}
Widget _icon(FileEntry e, bool isRoot, bool open) {
if (isRoot) return const Icon(Icons.sd_storage, color: JjColors.accent, size: 28);
if (e.isDir) return Icon(open ? Icons.folder_open : Icons.folder, color: Colors.amber, size: 30);
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 = switch (e.ext) {
'srt' || 'ass' || 'ssa' || 'smi' || 'sami' || 'vtt' => Icons.subtitles_outlined,
'mp3' || 'm4a' || 'flac' || 'wav' || 'ogg' || 'aac' || 'opus' => Icons.audiotrack_outlined,
'pdf' => Icons.picture_as_pdf_outlined,
'zip' || '7z' || 'rar' || 'tar' || 'gz' => Icons.folder_zip_outlined,
'apk' => Icons.android,
'txt' || 'log' || 'md' || 'json' || 'xml' => Icons.description_outlined,
_ => Icons.insert_drive_file_outlined,
};
return Icon(icon, size: 28, color: JjColors.textDim);
}
}
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),
]),
);
},
);
}