JJ_MKVMaker/lib/ui/explorer/file_thumbs.dart

175 lines
6.4 KiB
Dart
Raw 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: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<Completer<void>>();
ThumbQueue(this.max);
Future<T> run<T>(Future<T> Function() job) async {
if (_running >= max) {
final w = Completer<void>();
_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 = <String, Future<String>>{};
static final _q = ThumbQueue(2);
static Future<String> 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<String>(
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 = <String, Future<File?>>{};
static final _q = ThumbQueue(1);
/// 보관함에 넣은 파일의 기억을 잊는다 (관리자 지시 10/21)
static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|'));
static Future<File?> 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 = <String, Future<File?>>{};
static final _q = ThumbQueue(1);
static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|'));
static Future<File?> 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<File?>(
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),
]),
);
},
);
}