diff --git a/lib/ui/explorer/explorer_select.dart b/lib/ui/explorer/explorer_select.dart new file mode 100644 index 0000000..81073c5 --- /dev/null +++ b/lib/ui/explorer/explorer_select.dart @@ -0,0 +1,242 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show kDoubleTapTimeout; +import 'package:flutter/services.dart' show HardwareKeyboard; + +import '../../app/archive_browse.dart'; +import '../../core/explorer/tree_rows.dart'; +import '../../core/file_ops.dart' show FileEntry, isSameOrInside, samePath; +import '../../core/selection.dart'; +import '../../core/vfs.dart' show isDav, vDirname; +import '../file_error.dart' show explainFileError; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; + +/// 약속 (E5d-2): 탐색기 줄 누르기 · 고르기 (작은 컨트롤러). 규칙은 [ClickPolicy] (누르기 → 할 일) · [Selection] (표시) 이 +/// 가지고, 여기는 화면 상태 (선택 모드 · 지금 창 · 펼치기 · 읽기) 에 이어 줄 뿐이다. 창 · 읽기는 [ExplorerController]. +/// - 입력: 줄 누르기 [onTap] · 두 번 [onOpen] · ✔ 단추 [toggleMark] · Rsync [pick] · Shift 범위 · Ctrl 고르기. +/// - 파일을 여는 일은 부르는 쪽 [openFile] (file_opener 흐름). +/// - 지금 시각은 [clock] (폴더 두 번 누르기의 두 번째를 가리려고 - 시험은 정한 시각). +/// - 바뀌면 알림. 목록을 읽지 못하면 표시를 바꾸지 않고 까닭을 알림 (Host). +class ExplorerSelect extends ChangeNotifier { + ExplorerSelect(this.ctl, {required this.clock, required this.openFile}); + + final ExplorerController ctl; + final DateTime Function() clock; + final Future Function(String path, ExplorerPane pane) openFile; + + /// 선택 모드 (가운데 [선택]): 각 줄의 ✔ 가 보이고, 누르면 고르기 · 풀기 + bool selecting = false; + + /// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기) + bool get selectMode => ctl.c.settings.explorerClick != 'open'; + + /// 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고) + (String, DateTime)? _lastFolderTap; + + void setSelecting(bool on) { + selecting = on; + if (!on) { + for (final x in ctl.panes) { + x.sel.clear(); + } + } + notifyListeners(); + } + + void _activate(ExplorerPane pane) { + ctl.active = ctl.panes.indexOf(pane); + ctl.update(); + } + + /// 줄의 종류 ([ClickPolicy] 에 넘김) + static ItemKind kindOf(TreeRow row) => row.isUp + ? ItemKind.up + : row.isRoot + ? ItemKind.root + : row.entry.isDir + ? ItemKind.folder + : ItemKind.file; + + /// 한 번 누르기 - Rsync 는 폴더 하나 고르기, Shift = 범위 · Ctrl = 고르기 (PC), 그 밖은 [ClickPolicy] + Future onTap(ExplorerPane pane, TreeRow row, {bool list = false}) async { + final e = row.entry; + _activate(pane); + if (ctl.rsync && !row.isUp) return pick(pane, e); + final real = !row.isRoot && !row.isUp; + if (real && HardwareKeyboard.instance.isShiftPressed && canRange(pane, e)) { + if (!selecting) setSelecting(true); + return markRange(pane, e); + } + if (real && HardwareKeyboard.instance.isControlPressed) { + if (!selecting) setSelecting(true); + return toggleMark(pane, e); + } + final now = clock(); + final last = _lastFolderTap; + _lastFolderTap = (e.path, now); + final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; + final act = ClickPolicy.decide(Press.tap, kindOf(row), + selectMode: selecting, + expanded: pane.expanded.contains(e.path), + list: list, + repeat: repeat, + tapOpens: !selectMode, + retapCollapses: ctl.c.settings.explorerRetapCollapse); + await _act(pane, row, act, list: list); + } + + Future _act(ExplorerPane pane, TreeRow row, ClickAction act, {bool list = false}) async { + final e = row.entry; + switch (act) { + case ClickAction.none: + case ClickAction.menu: + return; + case ClickAction.focus: + pane.focused = e.path; + if (!list) pane.current = vDirname(e.path); + pane.changed(); + case ClickAction.open: + await onOpen(pane, row, list: list); + case ClickAction.collapse: + await toggle(pane, e); + case ClickAction.expand: + if (!pane.expanded.contains(e.path)) { + await toggle(pane, e); + } else if (!samePath(pane.current, e.path)) { + // 펼쳐 둔 폴더 (다시 눌러 접기를 끈 설정): 그 폴더를 지금 폴더로 + pane + ..current = e.path + ..focused = e.path + ..changed(); + ctl.remember(pane, e.path); + } + case ClickAction.toggleMark: + if (!selecting) setSelecting(true); + await toggleMark(pane, e); + } + } + + bool isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path); + + Future> _childPaths(ExplorerPane pane, String dir) async { + await ctl.load(pane, dir); + return [for (final x in pane.cache[dir] ?? const []) x.path]; + } + + /// Rsync 화면: 창마다 폴더 하나만 (규칙 8). 고르면 펼쳐서 안의 폴더가 보이게 + Future pick(ExplorerPane pane, FileEntry e) async { + if (!e.isDir) return; + await pane.sel.toggle(e.path, isDir: true, list: (d) => _childPaths(pane, d)); + if (!ctl.host.alive) return; + notifyListeners(); + if (!pane.sel.has(e.path)) return; + if (!pane.expanded.contains(e.path)) { + await toggle(pane, e); + } else { + pane + ..current = e.path + ..focused = e.path + ..changed(); + } + } + + /// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목 + bool canRange(ExplorerPane pane, FileEntry e) { + final a = pane.sel.anchor; + return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && isMarked(pane, a); + } + + /// 76: [dir] 에서 지금 보이는 것 (빠른 필터 · 목록에서 숨기기) + List shown(ExplorerPane pane, String dir) => [ + for (final x in pane.cache[dir] ?? const []) + if (passesFilter(pane, x) && (ctl.showAppHidden || !ctl.appHidden(x.path))) x + ]; + + void markRange(ExplorerPane pane, FileEntry to) { + pane.sel.range([for (final x in shown(pane, vDirname(to.path))) x.path], to.path); + pane.focused = to.path; + notifyListeners(); + } + + void invertMarks(ExplorerPane pane, String dir) { + pane.sel.invert([for (final x in shown(pane, dir)) x.path]); + notifyListeners(); + } + + void markWhere(ExplorerPane pane, String dir, bool Function(FileEntry e) test) { + pane.sel.addAll([for (final x in shown(pane, dir)) if (test(x)) x.path]); + notifyListeners(); + } + + /// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 + Future toggleMark(ExplorerPane pane, FileEntry e) async { + pane.focused = e.path; + try { + await pane.sel.toggle(e.path, isDir: e.isDir, list: (d) => _childPaths(pane, d)); + } on FileSystemException catch (err) { + ctl.host.snack(explainFileError('$err', dav: isDav(e.path)).$1); + } + if (!ctl.host.alive) return; + notifyListeners(); + if (e.isDir && pane.sel.has(e.path) && !pane.expanded.contains(e.path)) { + await ctl.load(pane, e.path); + if (ctl.host.alive) { + pane.expanded.add(e.path); + notifyListeners(); + } + } + } + + /// 열기: 트리의 폴더는 펼치기 (이름으로는 접지 않음 - 189), 목록의 폴더 · ".." 는 들어가기, 파일은 [openFile] + Future onOpen(ExplorerPane pane, TreeRow row, {bool list = false}) async { + final e = row.entry; + _activate(pane); + pane.focused = e.path; + if (row.isUp || (e.isDir && list)) return ctl.goTo(pane, e.path); + if (e.isDir) { + if (!pane.expanded.contains(e.path)) { + await toggle(pane, e); + } else if (!samePath(pane.current, e.path)) { + pane + ..current = e.path + ..changed(); + ctl.remember(pane, e.path); + } + return; + } + if (!list) pane.current = vDirname(e.path); + pane.changed(); + await openFile(e.path, pane); + } + + /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) + bool canBrowseArchive(FileEntry e) => !ctl.rsync && ArchiveBrowser.canBrowse(e); + + /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기). 압축은 폴더처럼 (읽기 전용) + Future toggle(ExplorerPane pane, FileEntry e) async { + _activate(pane); + if (canBrowseArchive(e) && !pane.expanded.contains(e.path)) { + if (!await ctl.readArchive(e.path)) return; + pane.cache.remove(e.path); + pane.expanded.add(e.path); + await ctl.load(pane, e.path); + return; + } + 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 ctl.load(pane, e.path); + if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); + ctl.remember(pane, e.path); + } +} diff --git a/lib/ui/explorer/file_thumbs.dart b/lib/ui/explorer/file_thumbs.dart new file mode 100644 index 0000000..e4adb28 --- /dev/null +++ b/lib/ui/explorer/file_thumbs.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/app_controller.dart'; +import '../../core/file_ops.dart' show FileEntry; +import '../theme.dart'; + +/// 탐색기 줄 · 빠른 보기의 동영상 정보 ("1920×1080 · 1:23:45") 와 썸네일 (E5d-2). +/// FFmpeg 는 차례로 (한 번에 하나 · 둘) 부르고, 만든 썸네일은 임시 폴더의 jj_thumbs 에 두어 다시 쓴다. +/// 다시 쓰는 곳: 탐색기 줄 · 빠른 보기 · 찾기 결과 · 보관함 (잊기는 thumbMemoryForgetters). + +// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ───────── + +/// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게) +class ThumbQueue { + final int max; + int _running = 0; + final _waiting = Queue>(); + ThumbQueue(this.max); + + Future run(Future Function() job) async { + if (_running >= max) { + final w = Completer(); + _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 VideoMeta { + static final _cache = >{}; + static final _q = ThumbQueue(2); + + static Future 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 VideoMetaText extends StatelessWidget { + final AppController c; + final String path; + + /// 앞에 붙일 글 (크기 · 날짜) - 동영상 정보와 한 줄로 이어 그린다 + final String lead; + final TextStyle? leadStyle; + const VideoMetaText({super.key, required this.c, required this.path, this.lead = '', this.leadStyle}); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: VideoMeta.of(c, path), + builder: (_, s) => Text.rich( + TextSpan(children: [ + if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle), + if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: TextStyle(fontSize: 11, color: JjColors.accent)), + ]), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ); +} + +/// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀). +class VideoThumbs { + static final _cache = >{}; + static final _q = ThumbQueue(1); + + /// 보관함에 넣은 파일의 기억을 잊는다 (관리자 지시 10/21) + static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|')); + + static Future 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; + }); + } +} + +/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다 +class BigVideoThumbs { + static final _cache = >{}; + static final _q = ThumbQueue(1); + + static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|')); + + static Future of(AppController c, FileEntry e) { + final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640'; + 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; + 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=640:-2', '-y', out.path, + ]); + if (await out.exists() && await out.length() > 0) return out; + } + } catch (_) {} + _cache.remove(key); + return null; + }); + } +} + +class VideoThumb extends StatelessWidget { + final AppController c; + final FileEntry entry; + const VideoThumb({super.key, required this.c, required this.entry}); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: VideoThumbs.of(c, entry), + builder: (_, s) { + final f = s.data; + if (f == null) return 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), + ]), + ); + }, + ); +} diff --git a/lib/ui/explorer/row_view.dart b/lib/ui/explorer/row_view.dart new file mode 100644 index 0000000..833199c --- /dev/null +++ b/lib/ui/explorer/row_view.dart @@ -0,0 +1,323 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/material.dart'; + +import '../../core/explorer/tree_rows.dart'; +import '../../core/file_ops.dart' show FileEntry, formatSize, samePath; +import '../../core/name_clash.dart' show fileDateText; +import '../../core/playlist.dart' show isVideoFile; +import '../../core/selection.dart'; +import '../../core/vfs.dart' show isDav; +import '../../l10n/tr.dart'; +import '../explorer_drag.dart'; +import '../explorer_look.dart'; +import '../remote_thumb_view.dart'; +import '../theme.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; +import 'file_thumbs.dart'; + +/// 189: 트리 화살표 칸의 폭 - 손가락으로 누르는 기기 (Android · iOS) 는 넓게 (줄 높이와 함께 누르는 영역) +double treeChevronWidth(TargetPlatform platform) => + platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20; + +/// 탐색기 줄 하나 (E5d-2). 상태 · 일은 [ExplorerController] · [ExplorerSelect], 메뉴 · 끌어 놓기는 부르는 쪽 콜백. +/// [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) +class ExplorerRowView extends StatelessWidget { + const ExplorerRowView({ + super.key, + required this.ctl, + required this.sel, + required this.pane, + required this.row, + this.list = false, + this.compact = false, + required this.rowHeight, + required this.narrowSplit, + required this.alwaysMarks, + required this.onMenu, + required this.dragPaths, + required this.onDrop, + }); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerPane pane; + final TreeRow row; + final bool list, compact; + final double rowHeight; + final bool narrowSplit, alwaysMarks; + + /// 길게 누르기 · 오른쪽 클릭 메뉴 + final void Function(ExplorerPane pane, FileEntry e, Offset at) onMenu; + + /// 끌 항목 (표시한 것 중 하나면 표시한 것 모두) + final List Function(ExplorerPane pane, FileEntry e) dragPaths; + + /// 폴더 줄 위에 놓음 + final Future Function(DragLoad load, int to, String dest) onDrop; + + @override + Widget build(BuildContext context) { + final e = row.entry; + final look = ctl.look; + // 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지 + final slim = compact && narrowSplit && !list; + // 이 줄은 목록 글자 배율을 이미 곱한 MediaQuery 안에 있다 (scaleListText) - 배율을 다시 곱하지 않는다 + final nameLines = slim && rowHeight >= MediaQuery.textScalerOf(context).scale(look.fontSize) * 2.5 + 6 ? 2 : 1; + 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 = sel.isMarked(pane, e.path); + final open = (e.isDir || ctl.arc.contains(e.path)) && !list && pane.expanded.contains(e.path); + final loading = pane.loading.contains(e.path); + final name = row.isUp + ? '..' + : row.isRoot + ? (ctl.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, + ); + + // 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림) + final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform); + // 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게, + // 손가락으로 누르는 기기 (Android) 는 폭도 넓게 + Widget chevron() => SizedBox( + width: chevronWidth, + height: rowHeight, + child: (e.isDir || sel.canBrowseArchive(e)) && !list && !row.isUp + ? (loading + ? const Center( + child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) + // 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기") + : Semantics( + button: true, + label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]), + excludeSemantics: true, + onTap: () => sel.toggle(pane, e), + child: InkResponse( + radius: chevronWidth / 2 + 4, + containedInkWell: false, + onTap: () => sel.toggle(pane, e), + child: Center( + child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), + ), + )) + : null, + ); + + // 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게) + Widget nameCell() => LayoutBuilder(builder: (context, box) { + // 이름 자리는 48 또는 줄 폭의 40% (관리자 지시 10/20: 글자가 크고 폴더가 깊으면 들여쓰기에 밀려 이름이 몇 글자만 남았다) + final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + math.max(48.0, box.maxWidth * 0.4); + // 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게 + final indent = list + ? 0.0 + : slim + ? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble() + : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble(); + // 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게 + final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity); + // 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림) + final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room); + final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW); + return Row(children: [ + SizedBox(width: indent), + if (!list) chevron(), + SizedBox( + width: iconW, + height: rowHeight - 8, + child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))), + ), + SizedBox(width: gapW), + 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), + // 좁은 화면 · 깊은 폴더에서 넘치지 않게. 960 캡처 (10/18): 동영상 정보와 한 줄 글로 (둘로 나누면 정보가 비어도 + // 폭의 반을 차지해 넓은 창에서도 날짜가 잘렸다) + isVideoFile(e.path) + ? VideoMetaText(c: ctl.c, path: e.path, lead: '${formatSize(e.size)} · ${fileDateText(e.modified)}', leadStyle: dim) + : Text('${formatSize(e.size)} · ${fileDateText(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle), + if (slim) + const SizedBox.shrink() + else if (look.rich && e.isDir && pane.errors[e.path] != null) + Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert)) + // 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기 + else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && ctl.dirSize(pane, e) != null) + Text(ctl.dirSize(pane, e)!, style: dim, maxLines: 1), + ], + ), + ), + ]); + }); + + final real = !row.isRoot && !row.isUp; + // 길게 · 오른쪽 = 메뉴 (규칙 2, [ClickPolicy]) + final menu = ClickPolicy.decide(Press.longPress, ExplorerSelect.kindOf(row), selectMode: sel.selecting) == ClickAction.menu; + 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), + ); + // 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel]) + final hide = pane.colHide; + final cells = !look.columns || compact || !real + ? [Expanded(child: nameCell())] + : look == ExplorerStyle.windows + ? [ + Expanded(flex: 6, child: nameCell()), + if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3), + if (hide < 1) cell(typeLabel(e), 2), + cell(e.isDir ? (ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), + ] + : [ + Expanded(flex: 6, child: nameCell()), + if (hide < 1) cell(e.isDir ? '' : e.ext, 1), + cell(e.isDir ? (ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), + if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)], + ]; + + final paneIndex = ctl.panes.indexOf(pane); + final drag = real && !ctl.rsync; + Widget line = GestureDetector( + // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) + // 25: Rsync 화면에서도 (폴더 메뉴) + onSecondaryTapUp: menu ? (d) => onMenu(pane, e, d.globalPosition) : null, + onLongPressStart: menu ? (d) => onMenu(pane, e, d.globalPosition) : null, + child: Material( + // U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대 + color: marked + ? JjColors.accent.withValues(alpha: 0.18) + : isFocused + ? JjColors.accent.withValues(alpha: 0.09) + : Colors.transparent, + shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null, + child: Row(children: [ + Expanded( + child: InkWell( + onTap: () => sel.onTap(pane, row, list: list), + // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게) + // 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게) + onDoubleTap: ClickPolicy.decide(Press.doubleTap, ExplorerSelect.kindOf(row), selectMode: sel.selecting, tapOpens: !sel.selectMode) == + ClickAction.open + ? () => sel.onOpen(pane, row, list: list) + : null, + child: Padding( + padding: const EdgeInsets.only(left: 6, right: 4), + child: Row(children: cells), + ), + ), + ), + // 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로) + if (drag && sel.selecting && marked) + Draggable( + data: DragLoad(paneIndex, dragPaths(pane, e)), + feedback: dragFeedback(dragPaths(pane, e)), + child: Tooltip( + message: tr('끌어서 다른 창으로'), + child: SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)), + ), + ), + // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) + if (slim) + const SizedBox.shrink() + else if ((real || (ctl.rsync && row.isRoot)) && (sel.selecting || ctl.rsync || alwaysMarks)) + SizedBox( + width: 36, + child: IconButton( + tooltip: ctl.rsync + ? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기')) + : marked + ? tr('표시 지우기') + : tr('표시 (여러 개 고르기)'), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + // 규칙 4: V 모양 - 고르지 않은 것은 흐리게, 고른 것은 진하게 + icon: Icon(Icons.check, + size: look.rich ? 24 : 20, + color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)), + onPressed: () { + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + ctl.rsync ? sel.pick(pane, e) : sel.toggleMark(pane, e); + }, + ), + ) + else if (sel.selecting || ctl.rsync || alwaysMarks) + const SizedBox(width: 36), + ]), + ), + ); + // X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게 + if (real && ctl.showAppHidden && ctl.appHidden(e.path)) line = Opacity(opacity: 0.5, child: line); + if (!drag) return line; + // 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조) + if (e.isDir) { + final inner = line; + line = DragTarget( + onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path), + onAcceptWithDetails: (d) => unawaited(onDrop(d.data, paneIndex, e.path)), + builder: (context, ok, _) => ok.isEmpty + ? inner + : DecoratedBox( + decoration: BoxDecoration( + color: JjColors.accent.withValues(alpha: 0.15), + border: Border.all(color: JjColors.accent, width: 1.5), + ), + child: inner, + ), + ); + } + // 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로) + final paths = dragPaths(pane, e); + return MouseDraggable(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line); + } + + Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { + final look = ctl.look; + if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim); + if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2); + // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘 + if (look.rich && isDav(e.path) && ctl.c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) { + final (icon, color) = fileIcon(look, e, open: open); + return RemoteThumb( + key: ValueKey('rthumb:${e.path}'), c: ctl.c, entry: e, fallback: Icon(icon, size: 28, color: color)); + } + // X-plore: 동영상 썸네일 · 그림 미리 보기 + if (look.rich && !e.isDir && !isDav(e.path)) { + if (isVideoFile(e.path)) return VideoThumb(c: ctl.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); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 293048a..db58449 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1,12 +1,10 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; -import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; -import 'package:flutter/gestures.dart' show kBackMouseButton, kDoubleTapTimeout, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent; +import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; @@ -16,7 +14,6 @@ import '../app/copy_center.dart'; import '../app/live_sync.dart'; import '../app/settings.dart' show FolderBookmark; import '../app/transfer_job.dart'; -import '../core/selection.dart'; import '../core/secret_gate.dart'; import '../core/sync_tools.dart'; import '../core/file_ops.dart'; @@ -56,7 +53,6 @@ import '../core/hwp_source.dart'; import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; -import 'remote_thumb_view.dart'; import 'tail_text.dart'; import '../core/thumb_cache.dart' show thumbMemoryForgetters; import '../core/shizuku.dart'; @@ -96,6 +92,9 @@ import 'explorer/rsync_run_dialog.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/row_view.dart'; export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch; export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; @@ -117,10 +116,6 @@ const double paneHeaderWideWidth = 480; /// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩) const double rsyncTwoPaneMinWidth = 560; -/// 189: 트리 화살표 칸의 폭 - 손가락으로 누르는 기기 (Android · iOS) 는 넓게 (줄 높이와 함께 누르는 영역) -double treeChevronWidth(TargetPlatform platform) => - platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20; - class ExplorerPage extends StatefulWidget { final AppController c; final bool rsync; @@ -310,8 +305,8 @@ class _ExplorerPageState extends State implements ExplorerHost { void initState() { // 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나) thumbMemoryForgetters - ..add(_Thumbs.forget) - ..add(_BigThumbs.forget); + ..add(VideoThumbs.forget) + ..add(BigVideoThumbs.forget); super.initState(); if (widget.rsync) { ExplorerPage._openRsync++; @@ -340,6 +335,7 @@ class _ExplorerPageState extends State implements ExplorerHost { ExplorerPage._open--; ExplorerPage.goToRequest.removeListener(_onGoToRequest); } + _sel.dispose(); _ctl.dispose(); if (_allFiles != null) _life.dispose(); _resumeReload.dispose(); @@ -849,8 +845,6 @@ class _ExplorerPageState extends State implements ExplorerHost { // 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게 (c.settings.explorerLayout == 'auto' && !isCompact(context)); - /// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기) - bool get _selectMode => c.settings.explorerClick != 'open'; /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) /// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정) @@ -987,237 +981,20 @@ class _ExplorerPageState extends State implements ExplorerHost { ); } - // ───────── 누르기 ───────── + // ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ───────── - /// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기 - bool _selecting = false; - - void _setSelecting(bool on) => setState(() { - _selecting = on; - if (!on) { - for (final x in _ctl.panes) { - x.sel.clear(); - } - } - }); - - /// 한 번 누르기. - /// - 선택 모드: 고르기 · 풀기 ([_toggleMark]) - /// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록) - /// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행 - /// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로. - /// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고) - (String, DateTime)? _lastFolderTap; - - Future _onTap(ExplorerPane pane, TreeRow row, {bool list = false}) async { - final e = row.entry; - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - if (widget.rsync && !row.isUp) { - await _pick(pane, e); - return; - } - final real = !row.isRoot && !row.isUp; - // 74: Shift + 누르기 = 마지막으로 표시한 것부터 여기까지 (PC) - if (real && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) { - if (!_selecting) setState(() => _selecting = true); - _markRange(pane, e); - return; - } - // PC: Ctrl + 누르기 = 선택 모드로 고르기 - if (real && HardwareKeyboard.instance.isControlPressed) { - if (!_selecting) setState(() => _selecting = true); - await _toggleMark(pane, e); - return; - } - // 폴더는 두 번 누르기를 기다리지 않는다 - 짧은 사이의 두 번째 누르기로 방금 펼친 것을 접지 않게 - final now = widget.clock(); - final last = _lastFolderTap; - _lastFolderTap = (e.path, now); - final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; - final act = ClickPolicy.decide(Press.tap, _kindOf(row), - selectMode: _selecting, - expanded: pane.expanded.contains(e.path), - list: list, - repeat: repeat, - tapOpens: !_selectMode, - retapCollapses: c.settings.explorerRetapCollapse); - await _act(pane, row, act, list: list); - } - - /// 줄의 종류 ([ClickPolicy] 에 넘김) - ItemKind _kindOf(TreeRow row) => row.isUp - ? ItemKind.up - : row.isRoot - ? ItemKind.root - : row.entry.isDir - ? ItemKind.folder - : ItemKind.file; - - /// [ClickPolicy] 가 정한 일을 한다 (화면 쪽 동작만 - 규칙은 [ClickPolicy] · [Selection]) - Future _act(ExplorerPane pane, TreeRow row, ClickAction act, {bool list = false}) async { - final e = row.entry; - switch (act) { - case ClickAction.none: - case ClickAction.menu: - return; - case ClickAction.focus: - pane.focused = e.path; - if (!list) pane.current = vDirname(e.path); - pane.changed(); - case ClickAction.open: - await _onOpen(pane, row, list: list); - case ClickAction.collapse: - await _toggle(pane, e); - case ClickAction.expand: - if (!pane.expanded.contains(e.path)) { - await _toggle(pane, e); - } else if (!samePath(pane.current, e.path)) { - // 펼쳐 둔 폴더 (다시 눌러 접기를 끈 설정): 그 폴더를 지금 폴더로 - pane - ..current = e.path - ..focused = e.path - ..changed(); - _ctl.remember(pane, e.path); - } - case ClickAction.toggleMark: - if (!_selecting) setState(() => _selecting = true); - await _toggleMark(pane, e); - } - } - - /// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것). - /// Rsync 화면은 고른 그 폴더만. - bool _isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path); - - /// [Selection] 이 폴더를 나눌 때 안의 경로 (읽어 둔 목록, 없으면 읽음) - Future> _childPaths(ExplorerPane pane, String dir) async { - await _ctl.load(pane, dir); - return [for (final x in pane.cache[dir] ?? const []) x.path]; - } - - /// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소, 규칙 8). 고르면 펼쳐서 안의 폴더가 보이게. - Future _pick(ExplorerPane pane, FileEntry e) async { - if (!e.isDir) return; - await pane.sel.toggle(e.path, isDir: true, list: (d) => _childPaths(pane, d)); - if (!mounted) return; - setState(() {}); - if (!pane.sel.has(e.path)) return; - if (!pane.expanded.contains(e.path)) { - await _toggle(pane, e); - } else { - pane - ..current = e.path - ..focused = e.path - ..changed(); - } - } - - /// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목 - bool _canRange(ExplorerPane pane, FileEntry e) { - final a = pane.sel.anchor; - return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a); - } - - /// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것) - /// X-plore 숨기기: 목록에서 숨긴 것은 [숨긴 항목 보이기] 를 켰을 때만 - List _shown(ExplorerPane pane, String dir) => [ - for (final x in pane.cache[dir] ?? const []) - if (passesFilter(pane, x) && (_ctl.showAppHidden || !_ctl.appHidden(x.path))) x - ]; - - /// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시 - void _markRange(ExplorerPane pane, FileEntry to) => setState(() { - pane.sel.range([for (final x in _shown(pane, vDirname(to.path))) x.path], to.path); - pane.focused = to.path; - }); - - /// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서 - void _invertMarks(ExplorerPane pane, String dir) => - setState(() => pane.sel.invert([for (final x in _shown(pane, dir)) x.path])); - - void _markWhere(ExplorerPane pane, String dir, bool Function(FileEntry e) test) => - setState(() => pane.sel.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path])); + late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _open(path, pane: pane)) + ..addListener(_onPane); Future _markPattern(ExplorerPane pane, String dir) async { final pattern = await showDialog(context: context, builder: (_) => const _PatternDialog()); if (pattern == null || pattern.trim().isEmpty) return; final re = globToRegExp(pattern); final before = pane.sel.marked.length; - _markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); + _sel.markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); if (pane.sel.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); } - /// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 보여 준다. - Future _toggleMark(ExplorerPane pane, FileEntry e) async { - pane.focused = e.path; - try { - await pane.sel.toggle(e.path, isDir: e.isDir, list: (d) => _childPaths(pane, d)); - } on FileSystemException catch (err) { - _snack(explainFileError('$err', dav: isDav(e.path)).$1); - } - if (!mounted) return; - setState(() {}); - if (e.isDir && pane.sel.has(e.path) && !pane.expanded.contains(e.path)) { - await _ctl.load(pane, e.path); - if (mounted) setState(() => pane.expanded.add(e.path)); - } - } - - /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): - /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. - Future _onOpen(ExplorerPane pane, TreeRow row, {bool list = false}) async { - final e = row.entry; - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - pane.focused = e.path; - if (row.isUp || (e.isDir && list)) { - await _ctl.goTo(pane, e.path); - return; - } - if (e.isDir) { - // 189: 이름을 누르면 그 폴더로 (닫혀 있으면 펼친다). 이름으로는 접지 않는다 - 다시 누르거나 두 번 누르면 접혀 - // 아래 줄이 움직여 다른 것을 누르게 된다. 접기 · 펼치기는 › 화살표로 (탐색기 · Total Commander 처럼) - if (!pane.expanded.contains(e.path)) { - await _toggle(pane, e); - } else if (!samePath(pane.current, e.path)) { - pane - ..current = e.path - ..changed(); - _ctl.remember(pane, e.path); - } - return; - } - if (!list) pane.current = vDirname(e.path); - pane.changed(); - await _open(e.path, pane: pane); - } - - /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) - Future _toggle(ExplorerPane pane, FileEntry e) async { - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - // X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용) - if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) { - if (!await _ctl.readArchive(e.path)) return; - pane.cache.remove(e.path); - pane.expanded.add(e.path); - await _ctl.load(pane, e.path); - return; - } - 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 _ctl.load(pane, e.path); - if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); - _ctl.remember(pane, e.path); - } - /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path) ? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.') @@ -1275,13 +1052,13 @@ class _ExplorerPageState extends State implements ExplorerHost { Future _grid(ExplorerPane pane, String dir) async { await _ctl.load(pane, dir); if (!mounted) return; - final list = _shown(pane, dir); + final list = _sel.shown(pane, dir); final at = await Navigator.of(context).push(MaterialPageRoute( builder: (_) => ExplorerGridPage( title: vBasename(dir).isEmpty ? dir : vBasename(dir), entries: list, imageExts: c.settings.imageExts, - videoThumb: (e) => _BigThumbs.of(c, e), + videoThumb: (e) => BigVideoThumbs.of(c, e), onOpen: (e) => _open(e.path, pane: pane), ), )); @@ -1544,7 +1321,7 @@ class _ExplorerPageState extends State implements ExplorerHost { (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = _dual; // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) - final targets = _isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; + final targets = _sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; final many = targets.length > 1; // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 final inArchive = targets.any(_insideArchive) || _insideArchive(e.path); @@ -1552,7 +1329,7 @@ class _ExplorerPageState extends State implements ExplorerHost { final items = widget.rsync ? <(String, IconData, String)>[ ('pickRsync', Icons.check_circle_outline, - _isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')), + _sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')), ('open', Icons.folder_open, tr('열기')), ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), @@ -1563,7 +1340,7 @@ class _ExplorerPageState extends State implements ExplorerHost { : <(String, IconData, String)>[ // 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목 // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 - if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), + if (_sel.selecting && _sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), // 81: 빠른 보기 (PC 는 Space · TC 는 F3) if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), @@ -1660,7 +1437,7 @@ class _ExplorerPageState extends State implements ExplorerHost { if (pick == null || !mounted) return; switch (pick) { case 'pickRsync': - await _pick(pane, e); + await _sel.pick(pane, e); case 'open': await _ctl.goTo(pane, e.path); case 'openOther': @@ -1695,10 +1472,10 @@ class _ExplorerPageState extends State implements ExplorerHost { case 'addMkv': await _addMkv([e.path]); case 'range': - _markRange(pane, e); + _sel.markRange(pane, e); case 'select': - setState(() => _selecting = true); - if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); + setState(() => _sel.selecting = true); + if (!_sel.isMarked(pane, e.path)) await _sel.toggleMark(pane, e); case 'quickLook': await _quickLook(pane, e.path); case 'extract': @@ -1708,7 +1485,7 @@ class _ExplorerPageState extends State implements ExplorerHost { case 'wifiShare': await _wifiShare(e.path); case 'zip': - final items = outermostPaths(_isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); + final items = outermostPaths(_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); if (made != null && mounted) { @@ -1764,19 +1541,19 @@ class _ExplorerPageState extends State implements ExplorerHost { action: (tr('취소'), () => _ctl.sizes.cancel(e.path))); } case 'rename': - if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) { + if (_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { await _batchRename(pane, pane.sel.marked); } else { await _rename(pane, e.path); } case 'copy': - await _transfer(pane, _isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); + await _transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'move': - await _transfer(pane, _isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); + await _transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'clipCopy': - _toClipboard(_isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); + _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'clipMove': - _toClipboard(_isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); + _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'paste': await _paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': @@ -1830,8 +1607,6 @@ class _ExplorerPageState extends State implements ExplorerHost { // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── - /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) - bool _canBrowseArchive(FileEntry e) => !widget.rsync && ArchiveBrowser.canBrowse(e); String? _archiveOf(String path) => _ctl.arc.archiveOf(path); bool _insideArchive(String path) => _ctl.arc.inside(path); @@ -1958,7 +1733,7 @@ class _ExplorerPageState extends State implements ExplorerHost { pane.expanded.remove(pane.current); await _ctl.goTo(pane, up); case ExplorerButton.select: - _setSelecting(!_selecting); + _sel.setSelecting(!_sel.selecting); case ExplorerButton.monitor: await MonitorPage.open(context, c); case ExplorerButton.toRight: @@ -2361,7 +2136,7 @@ class _ExplorerPageState extends State implements ExplorerHost { ]); for (final x in _ctl.panes) { paths.forEach(x.sel.forget); - if (_selecting && x.sel.isEmpty) _selecting = false; + if (_sel.selecting && x.sel.isEmpty) _sel.selecting = false; x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first); } @@ -3036,7 +2811,7 @@ class _ExplorerPageState extends State implements ExplorerHost { Future _info(FileEntry e) async { final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; - final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; + final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(c, e.path) : null; // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) final attrs = isDav(e.path) ? null : readFileAttributes(e.path); if (!mounted) return; @@ -3493,12 +3268,12 @@ class _ExplorerPageState extends State implements ExplorerHost { /// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님) bool get _backHasWork => - _selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root)); + _sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root)); /// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기) Future _onBack() async { - if (_selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { - _setSelecting(false); + if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { + _sel.setSelecting(false); return; } if (!samePath(_ctl.pane.current, _ctl.pane.root)) { @@ -3530,8 +3305,8 @@ class _ExplorerPageState extends State implements ExplorerHost { // 76: 열린 빠른 필터부터 닫는다 if (_ctl.panes[_ctl.active].filterOpen) { _closeFilter(_ctl.panes[_ctl.active]); - } else if (_selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { - _setSelecting(false); + } else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { + _sel.setSelecting(false); } }, }; @@ -3729,7 +3504,7 @@ class _ExplorerPageState extends State implements ExplorerHost { _ => true, }, selected: (b) => - (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting), + (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting), twoPanes: dual || split, pinned: {ExplorerButton.orient, if (_clip.isNotEmpty) ExplorerButton.paste}, ); @@ -3856,7 +3631,7 @@ class _ExplorerPageState extends State implements ExplorerHost { /// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나 List _dragPaths(ExplorerPane pane, FileEntry e) => - _isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; + _sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) @@ -3984,11 +3759,11 @@ class _ExplorerPageState extends State implements ExplorerHost { onSelected: (v) { switch (v) { case 'invert': - _invertMarks(pane, dir); + _sel.invertMarks(pane, dir); case 'pattern': _markPattern(pane, dir); case 'sameExt': - _markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); + _sel.markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); } }, itemBuilder: (_) => [ @@ -4011,12 +3786,12 @@ class _ExplorerPageState extends State implements ExplorerHost { TextButton( onPressed: () => setState(() { final dir = listDir ?? pane.current; - pane.sel.addAll([for (final x in _shown(pane, dir)) x.path]); + pane.sel.addAll([for (final x in _sel.shown(pane, dir)) x.path]); }), child: Text(tr('모두 선택')), ), TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))), - TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), + TextButton(onPressed: () => _sel.setSelecting(false), child: Text(tr('선택 끝'))), ]; return Container( color: JjColors.panel, @@ -4036,7 +3811,7 @@ class _ExplorerPageState extends State implements ExplorerHost { (TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1) ..layout()) .width; - final lay = columnLayout(look, width, marks: _selecting, textScale: scale, measure: measure); + final lay = columnLayout(look, width, marks: _sel.selecting, textScale: scale, measure: measure); pane.colHide = lay.$1; pane.colDateShort = lay.$2; } @@ -4060,7 +3835,7 @@ class _ExplorerPageState extends State implements ExplorerHost { if (look.columns && !foldersOnly) ExplorerColumnsHeader( style: look, - markColumn: _selecting, + markColumn: _sel.selecting, hide: pane.colHide, sortBy: _ctl.sortOf(pane.current).$1, sortDesc: _ctl.sortOf(pane.current).$2, @@ -4075,14 +3850,14 @@ class _ExplorerPageState extends State implements ExplorerHost { // 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey]) itemBuilder: (_, k) => KeyedSubtree( key: _rowKey(rows[k]), - child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], compact: foldersOnly)), + child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : ExplorerRowView(ctl: _ctl, sel: _sel, pane: pane, row: rows[k], compact: foldersOnly, rowHeight: _rowH(), narrowSplit: _narrowSplit, alwaysMarks: _alwaysMarks, onMenu: _menu, dragPaths: _dragPaths, onDrop: _onDrop)), findChildIndexCallback: _rowIndexOf(rows), )), // 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다) if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)), ]), ), - if ((_selecting || (_alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) _marksBar(pane), + if ((_sel.selecting || (_alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) _marksBar(pane), ]); }); } @@ -4104,7 +3879,7 @@ class _ExplorerPageState extends State implements ExplorerHost { final dir = pane.current; final entries = pane.cache[dir] ?? const []; final up = !samePath(dir, pane.root); - final shown = _shown(pane, dir); + final shown = _sel.shown(pane, dir); final hidden = entries.where((e) => _ctl.appHidden(e.path)).length; final rows = [ if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true), @@ -4129,7 +3904,7 @@ class _ExplorerPageState extends State implements ExplorerHost { if (look.columns) ExplorerColumnsHeader( style: look, - markColumn: _selecting, + markColumn: _sel.selecting, hide: pane.colHide, sortBy: _ctl.sortOf(dir).$1, sortDesc: _ctl.sortOf(dir).$2, @@ -4146,11 +3921,11 @@ class _ExplorerPageState extends State implements ExplorerHost { itemCount: rows.length, itemExtent: _rowH(), itemBuilder: (_, k) => KeyedSubtree( - key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], list: true)), + key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : ExplorerRowView(ctl: _ctl, sel: _sel, pane: pane, row: rows[k], list: true, rowHeight: _rowH(), narrowSplit: _narrowSplit, alwaysMarks: _alwaysMarks, onMenu: _menu, dragPaths: _dragPaths, onDrop: _onDrop)), findChildIndexCallback: _rowIndexOf(rows), )), ), - if (_selecting) _marksBar(pane, listDir: dir), + if (_sel.selecting) _marksBar(pane, listDir: dir), ]); }); } @@ -4205,8 +3980,8 @@ class _ExplorerPageState extends State implements ExplorerHost { 'clipCut': () => _toClipboard(_targets(_ctl.pane), move: true), 'paste': () => _paste(_ctl.pane, _ctl.pane.current), 'selectAll': () { - if (!_selecting) setState(() => _selecting = true); - _markWhere(_ctl.pane, _ctl.pane.current, (_) => true); + if (!_sel.selecting) setState(() => _sel.selecting = true); + _sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true); }, // TC: Space 표시 · Insert 표시하고 다음 줄로 'mark': () => _markFocused(next: false), @@ -4236,7 +4011,7 @@ class _ExplorerPageState extends State implements ExplorerHost { Future _quickLook(ExplorerPane pane, [String? path]) async { final f = path ?? pane.focused; if (f == null || samePath(f, pane.root)) return; - final list = _shown(pane, vDirname(f)); + final list = _sel.shown(pane, vDirname(f)); final i = list.indexWhere((x) => samePath(x.path, f)); if (i < 0) return; await showDialog( @@ -4245,7 +4020,7 @@ class _ExplorerPageState extends State implements ExplorerHost { entries: list, index: i, probe: c.services.mediaTool.probe, - thumbnail: (e) => _BigThumbs.of(c, e), + thumbnail: (e) => BigVideoThumbs.of(c, e), imageExts: c.settings.imageExts, onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)), ), @@ -4257,11 +4032,11 @@ class _ExplorerPageState extends State implements ExplorerHost { final pane = _ctl.pane; final f = pane.focused; if (f == null || samePath(f, pane.root)) return; - final list = _shown(pane, vDirname(f)); + final list = _sel.shown(pane, vDirname(f)); final i = list.indexWhere((x) => samePath(x.path, f)); if (i < 0) return; - if (!_selecting) setState(() => _selecting = true); - await _toggleMark(pane, list[i]); + if (!_sel.selecting) setState(() => _sel.selecting = true); + await _sel.toggleMark(pane, list[i]); if (next && i + 1 < list.length) { pane.focused = list[i + 1].path; pane.changed(); @@ -4662,263 +4437,6 @@ class _ExplorerPageState extends State implements ExplorerHost { } await _ctl.goTo(pane, '$smbScheme${saved.id}/'); } - - /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) - Widget _rowView(ExplorerPane pane, TreeRow row, {bool list = false, bool compact = false}) { - final e = row.entry; - final look = _ctl.look; - // 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지 - final slim = compact && _narrowSplit && !list; - final nameLines = slim && _rowH() >= MediaQuery.textScalerOf(context).scale(look.fontSize) * _listTextScale * 2.5 + 6 ? 2 : 1; - 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 || _ctl.arc.contains(e.path)) && !list && pane.expanded.contains(e.path); - final loading = pane.loading.contains(e.path); - final name = row.isUp - ? '..' - : row.isRoot - ? (_ctl.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, - ); - - // 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림) - final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform); - // 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게, - // 손가락으로 누르는 기기 (Android) 는 폭도 넓게 - Widget chevron() => SizedBox( - width: chevronWidth, - height: _rowH(), - child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp - ? (loading - ? const Center( - child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) - // 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기") - : Semantics( - button: true, - label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]), - excludeSemantics: true, - onTap: () => _toggle(pane, e), - child: InkResponse( - radius: chevronWidth / 2 + 4, - containedInkWell: false, - onTap: () => _toggle(pane, e), - child: Center( - child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), - ), - )) - : null, - ); - - // 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게) - Widget nameCell() => LayoutBuilder(builder: (context, box) { - // 이름 자리는 48 또는 줄 폭의 40% (관리자 지시 10/20: 글자가 크고 폴더가 깊으면 들여쓰기에 밀려 이름이 몇 글자만 남았다) - final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + math.max(48.0, box.maxWidth * 0.4); - // 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게 - final indent = list - ? 0.0 - : slim - ? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble() - : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble(); - // 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게 - final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity); - // 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림) - final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room); - final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW); - return Row(children: [ - SizedBox(width: indent), - if (!list) chevron(), - SizedBox( - width: iconW, - height: _rowH() - 8, - child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))), - ), - SizedBox(width: gapW), - 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), - // 좁은 화면 · 깊은 폴더에서 넘치지 않게. 960 캡처 (10/18): 동영상 정보와 한 줄 글로 (둘로 나누면 정보가 비어도 - // 폭의 반을 차지해 넓은 창에서도 날짜가 잘렸다) - isVideoFile(e.path) - ? _MetaText(c: c, path: e.path, lead: '${formatSize(e.size)} · ${_date(e.modified)}', leadStyle: dim) - : Text('${formatSize(e.size)} · ${_date(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle), - if (slim) - const SizedBox.shrink() - else if (look.rich && e.isDir && pane.errors[e.path] != null) - Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert)) - // 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기 - else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _ctl.dirSize(pane, e) != null) - Text(_ctl.dirSize(pane, e)!, style: dim, maxLines: 1), - ], - ), - ), - ]); - }); - - final real = !row.isRoot && !row.isUp; - // 길게 · 오른쪽 = 메뉴 (규칙 2, [ClickPolicy]) - final menu = ClickPolicy.decide(Press.longPress, _kindOf(row), selectMode: _selecting) == ClickAction.menu; - 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), - ); - // 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel]) - final hide = pane.colHide; - final cells = !look.columns || compact || !real - ? [Expanded(child: nameCell())] - : look == ExplorerStyle.windows - ? [ - Expanded(flex: 6, child: nameCell()), - if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3), - if (hide < 1) cell(typeLabel(e), 2), - cell(e.isDir ? (_ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), - ] - : [ - Expanded(flex: 6, child: nameCell()), - if (hide < 1) cell(e.isDir ? '' : e.ext, 1), - cell(e.isDir ? (_ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), - if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)], - ]; - - final paneIndex = _ctl.panes.indexOf(pane); - final drag = real && !widget.rsync; - Widget line = GestureDetector( - // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) - // 25: Rsync 화면에서도 (폴더 메뉴) - onSecondaryTapUp: menu ? (d) => _menu(pane, e, d.globalPosition) : null, - onLongPressStart: menu ? (d) => _menu(pane, e, d.globalPosition) : null, - child: Material( - // U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대 - color: marked - ? JjColors.accent.withValues(alpha: 0.18) - : isFocused - ? JjColors.accent.withValues(alpha: 0.09) - : Colors.transparent, - shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null, - child: Row(children: [ - Expanded( - child: InkWell( - onTap: () => _onTap(pane, row, list: list), - // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게) - // 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게) - onDoubleTap: ClickPolicy.decide(Press.doubleTap, _kindOf(row), selectMode: _selecting, tapOpens: !_selectMode) == - ClickAction.open - ? () => _onOpen(pane, row, list: list) - : null, - child: Padding( - padding: const EdgeInsets.only(left: 6, right: 4), - child: Row(children: cells), - ), - ), - ), - // 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로) - if (drag && _selecting && marked) - Draggable( - data: DragLoad(paneIndex, _dragPaths(pane, e)), - feedback: dragFeedback(_dragPaths(pane, e)), - child: Tooltip( - message: tr('끌어서 다른 창으로'), - child: SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)), - ), - ), - // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) - if (slim) - const SizedBox.shrink() - else if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks)) - SizedBox( - width: 36, - child: IconButton( - tooltip: widget.rsync - ? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기')) - : marked - ? tr('표시 지우기') - : tr('표시 (여러 개 고르기)'), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - // 규칙 4: V 모양 - 고르지 않은 것은 흐리게, 고른 것은 진하게 - icon: Icon(Icons.check, - size: look.rich ? 24 : 20, - color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)), - onPressed: () { - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - widget.rsync ? _pick(pane, e) : _toggleMark(pane, e); - }, - ), - ) - else if (_selecting || widget.rsync || _alwaysMarks) - const SizedBox(width: 36), - ]), - ), - ); - // X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게 - if (real && _ctl.showAppHidden && _ctl.appHidden(e.path)) line = Opacity(opacity: 0.5, child: line); - if (!drag) return line; - // 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조) - if (e.isDir) { - final inner = line; - line = DragTarget( - onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path), - onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, paneIndex, e.path)), - builder: (context, ok, _) => ok.isEmpty - ? inner - : DecoratedBox( - decoration: BoxDecoration( - color: JjColors.accent.withValues(alpha: 0.15), - border: Border.all(color: JjColors.accent, width: 1.5), - ), - child: inner, - ), - ); - } - // 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로) - final paths = _dragPaths(pane, e); - return MouseDraggable(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line); - } - - Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { - final look = _ctl.look; - if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim); - if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2); - // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘 - if (look.rich && isDav(e.path) && c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) { - final (icon, color) = fileIcon(look, e, open: open); - return RemoteThumb( - key: ValueKey('rthumb:${e.path}'), c: c, entry: e, fallback: Icon(icon, size: 28, color: color)); - } - // X-plore: 동영상 썸네일 · 그림 미리 보기 - if (look.rich && !e.isDir && !isDav(e.path)) { - 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); - } } /// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" @@ -5110,112 +4628,6 @@ class _Toolbar extends StatelessWidget { } } -// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ───────── - -/// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게) -class _Queue { - final int max; - int _running = 0; - final _waiting = Queue>(); - _Queue(this.max); - - Future run(Future Function() job) async { - if (_running >= max) { - final w = Completer(); - _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 = >{}; - static final _q = _Queue(2); - - static Future 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; - - /// 앞에 붙일 글 (크기 · 날짜) - 동영상 정보와 한 줄로 이어 그린다 - final String lead; - final TextStyle? leadStyle; - const _MetaText({required this.c, required this.path, this.lead = '', this.leadStyle}); - - @override - Widget build(BuildContext context) => FutureBuilder( - future: _Meta.of(c, path), - builder: (_, s) => Text.rich( - TextSpan(children: [ - if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle), - if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: TextStyle(fontSize: 11, color: JjColors.accent)), - ]), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ); -} - -/// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀). -class _Thumbs { - static final _cache = >{}; - static final _q = _Queue(1); - - /// 보관함에 넣은 파일의 기억을 잊는다 (관리자 지시 10/21) - static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|')); - - static Future 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; - }); - } -} - /// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침 class TransferProgressChip extends StatelessWidget { /// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지 @@ -5269,58 +4681,6 @@ class TransferProgressChip extends StatelessWidget { ); } -/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다 -class _BigThumbs { - static final _cache = >{}; - static final _q = _Queue(1); - - static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|')); - - static Future of(AppController c, FileEntry e) { - final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640'; - 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; - 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=640:-2', '-y', out.path, - ]); - if (await out.exists() && await out.length() > 0) return out; - } - } catch (_) {} - _cache.remove(key); - 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( - future: _Thumbs.of(c, entry), - builder: (_, s) { - final f = s.data; - if (f == null) return 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), - ]), - ); - }, - ); -} - /// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시) /// 74: 패턴 넣기 class _PatternDialog extends StatefulWidget { diff --git a/test/explorer_select_contract_test.dart b/test/explorer_select_contract_test.dart new file mode 100644 index 0000000..21d0a7e --- /dev/null +++ b/test/explorer_select_contract_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/explorer/tree_rows.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart'; +import 'package:path/path.dart' as p; + +/// E5d-2 약속 시험: lib/ui/explorer/explorer_select.dart 맨 위 약속 (실제 시험 폴더 · Host 는 실제 화면). +void main() { + late Directory tmp; + late String root, show, ep1, ep2; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_sel_ui_'); + root = tmp.path; + show = p.join(root, 'Show'); + Directory(show).createSync(); + ep1 = p.join(show, 'ep1.mkv'); + ep2 = p.join(show, 'ep2.mkv'); + File(ep1).writeAsStringSync('1'); + File(ep2).writeAsStringSync('2'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + var now = DateTime(2026, 10, 22, 9); + Future<(ExplorerSelect, ExplorerPane, List)> open(WidgetTester t, {bool rsync = false}) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey<_HostState>(); + await t.pumpWidget(MaterialApp(home: _Host(key: key))); + final ctl = ExplorerController(c, rsync: rsync, host: key.currentState!)..local = [(root, 'T:')]; + final opened = []; + final sel = ExplorerSelect(ctl, clock: () => now, openFile: (path, _) async => opened.add(path)); + addTearDown(() { + sel.dispose(); + ctl.dispose(); + }); + final q = ctl.panes[0]; + await t.runAsync(() => ctl.goTo(q, root)); + return (sel, q, opened); + } + + TreeRow rowOf(String path, {bool dir = false}) => TreeRow(FileEntry(path, isDir: dir, modified: DateTime(0)), 1); + + testWidgets('폴더 한 번 = 펼침 · 다시 = 접힘 (두 번 누르기의 두 번째는 아님) · 파일 한 번 = 지금 줄 · 두 번 = 열기', (t) async { + final (sel, q, opened) = await open(t); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + expect(q.expanded.contains(show), isTrue); + now = now.add(const Duration(milliseconds: 100)); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + expect(q.expanded.contains(show), isTrue, reason: '두 번 누르기의 두 번째'); + now = now.add(const Duration(seconds: 1)); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + expect(q.expanded.contains(show), isFalse, reason: '규칙 3'); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + await t.runAsync(() => sel.onTap(q, rowOf(ep1))); + expect([q.focused, opened], [ep1, []]); + await t.runAsync(() => sel.onOpen(q, rowOf(ep1))); + expect(opened, [ep1]); + }); + + testWidgets('선택 모드: 파일 고름 · 폴더 = 자기 + 안 · 범위 · 뒤집기 · 끄면 모두 풀림', (t) async { + final (sel, q, _) = await open(t); + sel.setSelecting(true); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + expect([sel.isMarked(q, ep1), q.expanded.contains(show)], [true, true]); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + expect(q.sel.isEmpty, isTrue); + await t.runAsync(() => sel.toggleMark(q, FileEntry(ep1, isDir: false, modified: DateTime(0)))); + expect(sel.canRange(q, FileEntry(ep2, isDir: false, modified: DateTime(0))), isTrue); + sel.markRange(q, FileEntry(ep2, isDir: false, modified: DateTime(0))); + expect(q.sel.marked.length, 2); + sel.invertMarks(q, show); + expect(q.sel.isEmpty, isTrue); + sel.markWhere(q, show, (e) => e.path.endsWith('2.mkv')); + expect(q.sel.marked, [ep2]); + sel.setSelecting(false); + expect([sel.selecting, q.sel.isEmpty], [false, true]); + }); + + testWidgets('Rsync: 폴더만 하나 · 고르면 펼침', (t) async { + final (sel, q, _) = await open(t, rsync: true); + await t.runAsync(() => sel.onTap(q, rowOf(ep1))); + expect(q.sel.isEmpty, isTrue); + await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true))); + expect([q.sel.marked, q.expanded.contains(show)], [ + [show], + true + ]); + }); +} + +class _Host extends StatefulWidget { + const _Host({super.key}); + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> implements ExplorerHost { + @override + bool get alive => mounted; + @override + void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + @override + void reveal(ExplorerPane pane, String path) {} + @override + void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); + @override + Widget build(BuildContext context) => const Scaffold(body: SizedBox()); +}