175 lines
6.4 KiB
Dart
175 lines
6.4 KiB
Dart
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),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|