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), ]), ); }, ); }