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 '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 readTextHead(String path, {int limit = 64 * 1024}) async { final bytes = []; 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('�')) { text = text.substring(0, text.length - 1); } } final bad = '�'.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 entries; final int index; /// 동영상 정보 (FFprobe) · 큰 썸네일 (없으면 null) final Future Function(String path) probe; final Future Function(FileEntry e) thumbnail; /// 그림으로 볼 확장자 final List 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 createState() => _QuickLookState(); } class _QuickLookState extends State { 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( 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 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( 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( // 큰 파일 · 느린 디스크에서도 창이 멈추지 않게 (기다리는 동안 "읽는 중", 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: [ TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기'))), FilledButton( onPressed: () { Navigator.pop(context); widget.onOpen(e); }, child: Text(e.isDir ? tr('열기') : tr('열기 · 재생')), ), ], ), ), ); } }