233 lines
9.8 KiB
Dart
233 lines
9.8 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../core/charset_detector.dart';
|
||
import '../core/file_ops.dart';
|
||
import '../core/text_codec.dart';
|
||
import '../core/models.dart';
|
||
import '../core/playlist.dart' show isAudioFile, isVideoFile;
|
||
import '../core/vfs.dart';
|
||
import '../l10n/tr.dart';
|
||
import 'file_tools.dart' show HexViewerPage;
|
||
import 'theme.dart';
|
||
|
||
/// 81: 빠른 보기에서 글로 보는 확장자
|
||
const textExtensions = ['txt', 'srt', 'ass', 'ssa', 'vtt', 'smi', 'log', 'md', 'json', 'xml', 'csv', 'ini', 'nfo'];
|
||
|
||
/// 81: 글 파일의 앞 64KB. 81 보충 (15 지적 · 관리자 판단): 자막과 같은 글자 코드 판별 (detectCharset · decodeText) 로
|
||
/// UTF-8 (BOM) · UTF-16 (BOM) · CP949 · Shift-JIS · GBK 를 읽는다 (MKV 화면과 같은 판단 - 한국어 .smi 는 대부분 CP949).
|
||
/// 그래도 깨진 글자가 많거나 글이 아니면 (NUL) null - 끝에서 잘린 한 글자는 깨진 것으로 치지 않는다
|
||
Future<String?> readTextHead(String path, {int limit = 64 * 1024}) async {
|
||
final bytes = <int>[];
|
||
await for (final chunk in File(path).openRead(0, limit)) {
|
||
bytes.addAll(chunk);
|
||
}
|
||
final cs = detectCharset(Uint8List.fromList(bytes));
|
||
var text = decodeText(bytes, cs);
|
||
if (bytes.length >= limit) {
|
||
while (text.endsWith('<EFBFBD>')) {
|
||
text = text.substring(0, text.length - 1);
|
||
}
|
||
}
|
||
final bad = '<EFBFBD>'.allMatches(text).length;
|
||
if (text.contains('\u0000') || bad > 2 + text.length ~/ 100) return null;
|
||
return text;
|
||
}
|
||
|
||
/// 81: 빠른 보기 (Space · TC 는 F3 · 길게 누르기 [미리 보기]) - 동영상은 큰 썸네일 · 길이 · 해상도 · 코덱, 그림은 그림,
|
||
/// 작은 글 파일은 앞부분, 그 밖은 크기 · 날짜. ← → 로 같은 폴더의 앞 · 다음 항목, Esc · Space 로 닫기, [열기].
|
||
/// 파일을 바꾸지 않는다 (읽기만).
|
||
class QuickLook extends StatefulWidget {
|
||
/// 같은 폴더에 보이는 항목 (차례대로) · 처음 보일 것
|
||
final List<FileEntry> entries;
|
||
final int index;
|
||
|
||
/// 동영상 정보 (FFprobe) · 큰 썸네일 (없으면 null)
|
||
final Future<MediaInfo> Function(String path) probe;
|
||
final Future<File?> Function(FileEntry e) thumbnail;
|
||
|
||
/// 그림으로 볼 확장자
|
||
final List<String> imageExts;
|
||
|
||
/// [열기] (닫은 뒤 탐색기가 연다)
|
||
final void Function(FileEntry e) onOpen;
|
||
|
||
const QuickLook({
|
||
super.key,
|
||
required this.entries,
|
||
required this.index,
|
||
required this.probe,
|
||
required this.thumbnail,
|
||
required this.imageExts,
|
||
required this.onOpen,
|
||
});
|
||
|
||
@override
|
||
State<QuickLook> createState() => _QuickLookState();
|
||
}
|
||
|
||
class _QuickLookState extends State<QuickLook> {
|
||
late int _i = widget.index;
|
||
final _focus = FocusNode(debugLabel: 'quickLook');
|
||
|
||
FileEntry get _e => widget.entries[_i];
|
||
|
||
@override
|
||
void dispose() {
|
||
_focus.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _go(int d) {
|
||
final n = _i + d;
|
||
if (n < 0 || n >= widget.entries.length) return;
|
||
setState(() => _i = n);
|
||
}
|
||
|
||
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)}';
|
||
}
|
||
|
||
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)}';
|
||
}
|
||
|
||
Widget _body(FileEntry e) {
|
||
final dav = isDav(e.path);
|
||
if (e.isDir) return _info([tr('폴더')]);
|
||
if (!dav && (isVideoFile(e.path) || isAudioFile(e.path))) return _media(e);
|
||
if (!dav && widget.imageExts.contains(e.ext)) {
|
||
return Image.file(File(e.path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _info([tr('그림을 열 수 없습니다')]));
|
||
}
|
||
// 관리자 판단: 글 파일은 앞 64KB 만, 글자 코드가 깨지면 "미리 볼 수 없음"
|
||
if (!dav && e.size > 0 && textExtensions.contains(e.ext)) {
|
||
return FutureBuilder<String?>(
|
||
future: readTextHead(e.path),
|
||
builder: (_, s) => s.connectionState != ConnectionState.done
|
||
? const Center(child: CircularProgressIndicator())
|
||
: s.data == null
|
||
? _info([tr('미리 볼 수 없음 (글자 코드를 알 수 없습니다)')])
|
||
: SingleChildScrollView(
|
||
child: SelectableText(s.data!, style: const TextStyle(fontFamily: 'Consolas', fontSize: 12)),
|
||
),
|
||
);
|
||
}
|
||
return _info([if (dav) tr('WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다')]);
|
||
}
|
||
|
||
Widget _info(List<String> lines) => Center(
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(_e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, size: 56, color: JjColors.textDim),
|
||
for (final l in lines) Text(l, textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)),
|
||
]),
|
||
);
|
||
|
||
Widget _media(FileEntry e) => Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||
if (isVideoFile(e.path))
|
||
Expanded(
|
||
child: FutureBuilder<File?>(
|
||
future: widget.thumbnail(e),
|
||
builder: (_, s) => s.connectionState != ConnectionState.done
|
||
? const Center(child: CircularProgressIndicator())
|
||
: s.data == null
|
||
? const Center(child: Icon(Icons.movie_outlined, size: 56, color: JjColors.textDim))
|
||
: Image.file(s.data!, fit: BoxFit.contain),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FutureBuilder<MediaInfo>(
|
||
// 큰 파일 · 느린 디스크에서도 창이 멈추지 않게 (기다리는 동안 "읽는 중", 20초 넘으면 못 읽음)
|
||
future: widget.probe(e.path).timeout(const Duration(seconds: 20)),
|
||
builder: (_, s) {
|
||
if (s.connectionState != ConnectionState.done) return Text(tr('정보를 읽는 중…'), style: const TextStyle(color: JjColors.textDim));
|
||
final info = s.data;
|
||
if (info == null) return Text(tr('정보를 읽지 못했습니다'), style: const TextStyle(color: JjColors.textDim));
|
||
final v = info.ofType('video').where((x) => (x.width ?? 0) > 0).firstOrNull;
|
||
final audio = info.ofType('audio');
|
||
final subs = info.ofType('subtitle');
|
||
return SelectableText(
|
||
[
|
||
if (info.duration != null) '${tr('길이')}: ${_dur(info.duration!)}',
|
||
if (v != null) '${tr('영상')}: ${v.codec} · ${v.width}×${v.height}',
|
||
if (audio.isNotEmpty)
|
||
'${tr('소리')}: ${audio.map((a) => [a.codec, if (a.language.isNotEmpty) a.language].join(' ')).join(', ')}',
|
||
if (subs.isNotEmpty)
|
||
trf('자막 트랙 {0}개: {1}', [subs.length, subs.map((x) => [x.codec, if (x.language.isNotEmpty) x.language].join(' ')).join(', ')]),
|
||
].join('\n'),
|
||
style: const TextStyle(fontSize: 13),
|
||
);
|
||
},
|
||
),
|
||
]);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final e = _e;
|
||
return CallbackShortcuts(
|
||
bindings: {
|
||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _go(-1),
|
||
const SingleActivator(LogicalKeyboardKey.arrowRight): () => _go(1),
|
||
const SingleActivator(LogicalKeyboardKey.arrowUp): () => _go(-1),
|
||
const SingleActivator(LogicalKeyboardKey.arrowDown): () => _go(1),
|
||
const SingleActivator(LogicalKeyboardKey.space): () => Navigator.pop(context),
|
||
const SingleActivator(LogicalKeyboardKey.enter): () {
|
||
Navigator.pop(context);
|
||
widget.onOpen(e);
|
||
},
|
||
},
|
||
child: Focus(
|
||
focusNode: _focus,
|
||
autofocus: true,
|
||
child: AlertDialog(
|
||
titlePadding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
|
||
title: Row(children: [
|
||
Expanded(child: Text(e.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16))),
|
||
IconButton(tooltip: tr('앞 항목 (←)'), onPressed: _i > 0 ? () => _go(-1) : null, icon: const Icon(Icons.chevron_left)),
|
||
Text('${_i + 1}/${widget.entries.length}', style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
IconButton(
|
||
tooltip: tr('다음 항목 (→)'),
|
||
onPressed: _i < widget.entries.length - 1 ? () => _go(1) : null,
|
||
icon: const Icon(Icons.chevron_right)),
|
||
]),
|
||
content: SizedBox(
|
||
width: 640,
|
||
height: 420,
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||
Text(
|
||
[if (!e.isDir) formatSize(e.size), if (e.modified.year > 1) _date(e.modified), vDirname(e.path)].join(' · '),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(child: KeyedSubtree(key: ValueKey(e.path), child: _body(e))),
|
||
]),
|
||
),
|
||
actions: [
|
||
// X-plore P3: 16진 보기 (이 기기의 파일만 - 앞부분부터 나눠 읽기)
|
||
if (!e.isDir && !isDav(e.path))
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => HexViewerPage(path: e.path))),
|
||
child: Text(tr('16진')),
|
||
),
|
||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기'))),
|
||
FilledButton(
|
||
onPressed: () {
|
||
Navigator.pop(context);
|
||
widget.onOpen(e);
|
||
},
|
||
child: Text(e.isDir ? tr('열기') : tr('열기 · 재생')),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|