JJ_MKVMaker/lib/ui/explorer_search.dart

520 lines
21 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../core/file_ops.dart';
import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/subtitle_detector.dart' show audioExtensions, subtitleExtensions, videoExtensions;
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'app_actions.dart' show isCompact;
import 'theme.dart';
import 'jj_title_bar.dart';
import 'jj_chip.dart';
/// 79: 찾기 조건 - 이름 (없어도 됨) · 크기 · 바뀐 날 · 종류 ("1GB 넘는 30일 지난 영상")
class SearchConditions {
/// 이름 (비우면 이름은 보지 않음). * · ? 를 쓸 수 있다
final String name;
/// 이 크기 이상 (바이트, null = 보지 않음)
final int? minBytes;
/// 이 날 수보다 오래전에 바뀐 것만 (null = 보지 않음)
final int? olderDays;
/// 이 날 수 안에 바뀐 것만 (null = 보지 않음)
final int? newerDays;
/// 'all' · 'video' · 'audio' · 'subtitle' · 'image' · 'custom' ([exts])
final String kind;
/// 'custom' 일 때 확장자 ("mkv, mp4")
final String exts;
const SearchConditions({this.name = '', this.minBytes, this.olderDays, this.newerDays, this.kind = 'all', this.exts = ''});
/// 이름 말고 다른 조건이 있는지 (있으면 파일만 찾는다 - 폴더에는 크기 · 종류가 없다)
bool get hasFileConditions => minBytes != null || olderDays != null || newerDays != null || kind != 'all';
bool get isEmpty => name.trim().isEmpty && !hasFileConditions;
List<String> get _kindExts => switch (kind) {
'video' => videoExtensions,
'audio' => audioExtensions,
'subtitle' => subtitleExtensions,
'image' => defaultImageExtensions,
'custom' => [
for (final x in exts.split(RegExp(r'[,;\s]+')))
if (x.trim().isNotEmpty) x.trim().toLowerCase().replaceFirst(RegExp(r'^\*?\.'), ''),
],
_ => const [],
};
/// 이름 말고 다른 조건에 맞는지 ([now] 는 시험에서 바꿔 끼움)
bool accepts(FileEntry e, {DateTime? now}) {
if (!hasFileConditions) return true;
if (e.isDir) return false;
if (minBytes != null && e.size < minBytes!) return false;
final t = now ?? DateTime.now();
if (olderDays != null && e.modified.isAfter(t.subtract(Duration(days: olderDays!)))) return false;
if (newerDays != null && e.modified.isBefore(t.subtract(Duration(days: newerDays!)))) return false;
if (kind != 'all' && !_kindExts.contains(e.ext)) return false;
return true;
}
/// 화면에 보일 요약: "이름 *.mkv · 1GB 이상 · 30일 지남 · 동영상"
String get summary => [
if (name.trim().isNotEmpty) '"${name.trim()}"',
if (minBytes != null) trf('{0} 이상', [formatSize(minBytes!)]),
if (olderDays != null) trf('{0}일 지남', [olderDays!]),
if (newerDays != null) trf('{0}일 안', [newerDays!]),
if (kind != 'all') kindLabel(kind, exts),
].join(' · ');
static String kindLabel(String k, [String exts = '']) => switch (k) {
'video' => tr('동영상'),
'audio' => tr('음악'),
'subtitle' => tr('자막'),
'image' => tr('그림'),
'custom' => exts.trim().isEmpty ? tr('확장자 직접') : exts.trim(),
_ => tr('모두'),
};
}
/// 79: 찾기 조건 창. 취소하면 null
Future<SearchConditions?> askSearchConditions(BuildContext context, String where, SearchConditions last) =>
showDialog<SearchConditions>(context: context, builder: (_) => _ConditionsDialog(where: where, last: last));
class _ConditionsDialog extends StatefulWidget {
final String where;
final SearchConditions last;
const _ConditionsDialog({required this.where, required this.last});
@override
State<_ConditionsDialog> createState() => _ConditionsDialogState();
}
class _ConditionsDialogState extends State<_ConditionsDialog> {
late final _name = TextEditingController(text: widget.last.name);
late final _size = TextEditingController(text: _sizeText(widget.last.minBytes));
late final _days = TextEditingController(text: '${widget.last.olderDays ?? widget.last.newerDays ?? ''}');
late final _exts = TextEditingController(text: widget.last.exts);
late String _unit = (widget.last.minBytes ?? 0) >= _gb && (widget.last.minBytes! % _gb == 0) ? 'GB' : 'MB';
late String _when = widget.last.olderDays != null ? 'older' : (widget.last.newerDays != null ? 'newer' : 'any');
late String _kind = widget.last.kind;
static const _mb = 1024 * 1024;
static const _gb = 1024 * _mb;
static String _sizeText(int? b) {
if (b == null) return '';
if (b >= _gb && b % _gb == 0) return '${b ~/ _gb}';
return '${(b / _mb).round()}';
}
@override
void dispose() {
_name.dispose();
_size.dispose();
_days.dispose();
_exts.dispose();
super.dispose();
}
SearchConditions get _value {
final size = double.tryParse(_size.text.trim());
final days = int.tryParse(_days.text.trim());
return SearchConditions(
name: _name.text.trim(),
minBytes: size == null || size <= 0 ? null : (size * (_unit == 'GB' ? _gb : _mb)).round(),
olderDays: _when == 'older' && days != null && days > 0 ? days : null,
newerDays: _when == 'newer' && days != null && days > 0 ? days : null,
kind: _kind,
exts: _exts.text.trim(),
);
}
void _submit() {
final v = _value;
if (v.isEmpty) return;
Navigator.pop(context, v);
}
@override
Widget build(BuildContext context) {
final v = _value;
return AlertDialog(
scrollable: true,
title: Text(trf('찾기: {0}', [widget.where])),
content: SizedBox(
width: 460,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(
controller: _name,
autofocus: true,
decoration: InputDecoration(
labelText: tr('이름 (비워도 됨)'),
hintText: tr('예: S01E · *.srt'),
isDense: true,
),
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 12),
// 크기: 이 크기 이상
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
SizedBox(
width: 110,
child: TextField(
controller: _size,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: tr('크기 이상'), isDense: true),
onChanged: (_) => setState(() {}),
),
),
DropdownButton<String>(
value: _unit,
items: const [
DropdownMenuItem(value: 'MB', child: Text('MB')),
DropdownMenuItem(value: 'GB', child: Text('GB')),
],
onChanged: (x) => setState(() => _unit = x!),
),
]),
const SizedBox(height: 8),
// 바뀐 날: 아무 때 · N일 지남 · N일 안
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
DropdownButton<String>(
value: _when,
items: [
DropdownMenuItem(value: 'any', child: Text(tr('바뀐 날: 아무 때'))),
DropdownMenuItem(value: 'older', child: Text(tr('바뀐 지 … 일 지남'))),
DropdownMenuItem(value: 'newer', child: Text(tr('바뀐 지 … 일 안'))),
],
onChanged: (x) => setState(() => _when = x!),
),
if (_when != 'any')
SizedBox(
width: 80,
child: TextField(
controller: _days,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: tr('일'), isDense: true),
onChanged: (_) => setState(() {}),
),
),
]),
const SizedBox(height: 8),
// 종류
Wrap(spacing: 6, runSpacing: 4, children: [
for (final k in const ['all', 'video', 'audio', 'subtitle', 'image', 'custom'])
JjChip(
label: SearchConditions.kindLabel(k),
selected: _kind == k,
onSelected: (_) => setState(() => _kind = k),
),
]),
if (_kind == 'custom')
TextField(
controller: _exts,
decoration: InputDecoration(labelText: tr('확장자 (쉼표로)'), hintText: 'mkv, mp4', isDense: true),
onChanged: (_) => setState(() {}),
),
if (v.hasFileConditions)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(tr('크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(onPressed: v.isEmpty ? null : _submit, child: Text(tr('찾기'))),
],
);
}
}
/// 79: 찾기 결과 화면에서 고른 일 (화면을 닫고 탐색기가 한다 - 진행 패널이 보이게)
class SearchAction {
/// 'reveal' (그 자리로) · 'copy' · 'move' · 'delete'
final String kind;
final List<String> paths;
/// 79-a: 그때까지 찾은 것 · 다 찾았는지 (탐색기의 [찾기 결과로 돌아가기] 에 쓴다)
final List<FileEntry> found;
final bool done;
const SearchAction(this.kind, this.paths, {this.found = const [], this.done = true});
}
/// 79: 찾기 결과를 목록으로 - 고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생 (화면은 그대로).
/// 2000개마다 멈추고 [더 보기]
class SearchResultsPage extends StatefulWidget {
final String root;
final SearchConditions cond;
final bool showHidden;
/// 열기 · 재생 (탐색기와 같게). 결과 화면은 닫지 않는다
final Future<void> Function(FileEntry e) onOpen;
/// 복사 · 이동할 곳 (다른 창의 지금 폴더, 없으면 null - 복사 · 이동은 담아 두기)
final String? otherDir;
/// 한 번에 보여 줄 개수 (멈추고 [더 보기])
final int chunk;
/// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고 이 목록을 보인다 ([initialDone] 이 거짓이면 그때 다 찾지 못한 것)
final List<FileEntry>? initial;
final bool initialDone;
const SearchResultsPage({
super.key,
required this.root,
required this.cond,
required this.showHidden,
required this.onOpen,
this.otherDir,
this.chunk = 2000,
this.initial,
this.initialDone = true,
});
@override
State<SearchResultsPage> createState() => _SearchResultsPageState();
}
class _SearchResultsPageState extends State<SearchResultsPage> {
final _found = <FileEntry>[];
final _marked = <String>{};
final _skipped = <(String, String)>[];
StreamSubscription<FileEntry>? _sub;
bool _done = false;
bool _paused = false;
late int _until = widget.chunk;
/// 한도를 넘은 첫 항목 - 이것이 왔을 때만 멈추고 [더 보기] (딱 한도만큼이면 멈췄다고 하지 않는다)
FileEntry? _held;
@override
void initState() {
super.initState();
if (widget.initial != null) {
_found.addAll(widget.initial!);
_done = true;
return;
}
final c = widget.cond;
_sub = searchFiles(widget.root, c.name,
showHidden: widget.showHidden,
limit: 1 << 40,
accept: c.hasFileConditions ? c.accepts : null, onSkipped: (dir, e) {
if (mounted) setState(() => _skipped.add((dir, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e')));
}).listen(
(e) {
if (!mounted) return;
// 한 번에 [chunk] 개 - 그다음 것이 오면 붙잡아 두고 멈춘 뒤 [더 보기] 를 기다린다
if (_found.length >= _until) {
_held = e;
_sub?.pause();
setState(() => _paused = true);
return;
}
setState(() => _found.add(e));
},
onDone: () {
if (mounted) setState(() => _done = true);
},
onError: (_) {},
);
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
void _more() {
setState(() {
_until += widget.chunk;
_paused = false;
if (_held != null) _found.add(_held!);
_held = null;
});
_sub?.resume();
}
/// 79-c: 고른 것 중 바깥 것만 (고른 폴더 안의 파일은 그 폴더와 함께 한 번만 처리)
List<FileEntry> get _targets {
final keep = outermostPaths([for (final e in _found) if (_marked.contains(e.path)) e.path]).toSet();
return [for (final e in _found) if (keep.contains(e.path)) e];
}
/// 79-b: 아래 줄 - "파일 n개 · 크기", 폴더가 있으면 "폴더 m개 (크기 미포함)" 를 따로 (합계가 실제보다 작아 보이지 않게)
String _summary(List<FileEntry> t) {
final files = t.where((e) => !e.isDir).toList();
final dirs = t.length - files.length;
return [
if (files.isNotEmpty) trf('파일 {0}개 · {1}', [files.length, formatSize(files.fold(0, (a, e) => a + e.size))]),
if (dirs > 0) trf('폴더 {0}개 (크기 미포함)', [dirs]),
].join(' / ');
}
void _act(String kind) => Navigator.pop(
context, SearchAction(kind, [for (final e in _targets) e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused));
@override
Widget build(BuildContext context) {
final compact = isCompact(context);
final any = _marked.isNotEmpty;
final allMarked = _found.isNotEmpty && _marked.length == _found.length;
return Scaffold(
appBar: jjTitleBar(
context,
title: trf('찾기 결과 · {0}개', [_found.length]),
actions: [
if (!_done && !_paused)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
),
// 79-d: 찾는 중에는 "지금까지 찾은 n개 모두" (뒤에 나올 것은 고르지 않는다는 것이 보이게), 다 찾으면 "모두"
if (_found.isNotEmpty)
TextButton.icon(
icon: Icon(allMarked ? Icons.deselect : Icons.select_all, size: 18),
label: Text(allMarked
? tr('모두 풀기')
: _done
? tr('모두')
: trf('지금까지 찾은 {0}개 모두', [_found.length])),
onPressed: () => setState(() => allMarked ? _marked.clear() : _marked.addAll(_found.map((e) => e.path))),
),
],
),
body: Column(children: [
// 어디서 무엇을
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('${widget.cond.summary} · ${widget.root}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
),
Expanded(
child: _found.isEmpty && _done
? Center(child: Text(tr('찾은 것이 없습니다.')))
: ListView.builder(
itemCount: _found.length,
itemBuilder: (_, i) {
final e = _found[i];
final on = _marked.contains(e.path);
return ListTile(
dense: true,
selected: on,
leading: Checkbox(
value: on,
onChanged: (_) => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)),
),
title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis),
// 폴더는 찾기 시작한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 이 줄을 길게 누르면
subtitle: Tooltip(
message: vDisplay(vDirname(e.path)),
triggerMode: TooltipTriggerMode.longPress,
child: Text(
[
shortUnder(widget.root, vDirname(e.path)),
if (!e.isDir) formatSize(e.size),
if (e.modified.year > 1) _date(e.modified),
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
trailing: IconButton(
tooltip: tr('그 자리로'),
icon: const Icon(Icons.drive_file_move_outline, size: 20),
onPressed: () => Navigator.pop(context, SearchAction('reveal', [e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)),
),
// 고르는 중이면 누르기 = 고르기, 아니면 파일은 열기 · 재생 (화면은 그대로), 폴더는 그 폴더로
onTap: any
? () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path))
: e.isDir
? () => Navigator.pop(context, SearchAction('reveal', [e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused))
: () => widget.onOpen(e),
onLongPress: () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)),
);
},
),
),
if (widget.initial != null && !widget.initialDone)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text(tr('처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
),
if (_paused)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(trf('{0}개에서 멈췄습니다', [_found.length]), style: TextStyle(fontSize: 12, color: JjColors.warning)),
OutlinedButton(onPressed: _more, child: Text(trf('{0}개 더 보기', [widget.chunk]))),
]),
),
if (_skipped.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
title: Text(trf('읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다', [_skipped.length]),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
children: [
for (final (dir, why) in _skipped.take(50))
Align(
alignment: Alignment.centerLeft,
child: Text('$dir · $why', style: TextStyle(fontSize: 11, color: JjColors.textDim)),
),
],
),
),
// 고른 것으로 할 일 (고른 것만 대상 - 154 · 155)
if (any)
SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(border: Border(top: BorderSide(color: JjColors.border))),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Wrap(spacing: 4, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(_summary(_targets), style: const TextStyle(fontSize: 12)),
TextButton.icon(
onPressed: () => _act('copy'),
icon: const Icon(Icons.copy, size: 18),
label: Text(widget.otherDir == null || compact ? tr('복사') : trf('복사 → {0}', [vBasename(widget.otherDir!)])),
),
TextButton.icon(
onPressed: () => _act('move'),
icon: const Icon(Icons.drive_file_move, size: 18),
label: Text(widget.otherDir == null || compact ? tr('이동') : trf('이동 → {0}', [vBasename(widget.otherDir!)])),
),
TextButton.icon(
onPressed: () => _act('delete'),
icon: Icon(Icons.delete_outline, size: 18, color: JjColors.danger),
label: Text(tr('지우기'), style: TextStyle(color: JjColors.danger)),
),
]),
),
),
]),
);
}
static String _date(DateTime d) {
String two(int x) => x.toString().padLeft(2, '0');
return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}';
}
}