JJ_MKVMaker/lib/ui/quick_look.dart

233 lines
9.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

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

import 'dart:async';
import 'dart:io';
import 'package:flutter/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: 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
? 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: TextStyle(color: JjColors.textDim));
final info = s.data;
if (info == null) return Text(tr('정보를 읽지 못했습니다'), style: 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: 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: 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('열기 · 재생')),
),
],
),
),
);
}
}