JJ_MKVMaker/lib/ui/remote_thumb_view.dart

160 lines
6.2 KiB
Dart

import 'dart:async';
import 'dart:io';
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 '../core/lan_scan.dart' show activeMeteredOf;
import '../core/playlist.dart' show isVideoFile;
import '../core/remote_stream.dart';
import '../core/remote_thumbs.dart';
import '../core/shizuku.dart' show isShizuku;
import '../core/thumb_cache.dart' show thumbMemoryForgetters;
import '../core/vfs.dart' show DavPath, isWebDav;
import '../core/webdav.dart' show DavClient;
import 'theme.dart';
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일을 쓰는 쪽 (설정 · 망 · FFmpeg) - 앱에 하나
Future<RemoteThumbs>? _hub;
Future<RemoteThumbs> remoteThumbsOf(AppController c) => _hub ??= () async {
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_rthumbs'));
final h = RemoteThumbs(
dir: dir,
maxBytes: () => c.settings.explorer.remoteThumbCacheMb * 1024 * 1024,
allowed: () => remoteThumbsAllowedNow(c),
make: (path, out) => _make(c, path, out),
);
thumbMemoryForgetters.add(h.forget);
return h;
}();
/// 시험: 다음에 새로 만든다
void resetRemoteThumbsForTest() => _hub = null;
/// 원격 그림은 이보다 크면 받지 않는다 (썸네일 하나에 큰 파일을 통째로 받지 않게)
const remoteThumbImageMax = 30 * 1024 * 1024;
/// 동영상 썸네일 하나에 받는 양의 상한 (관리자 지시 10/21 - 넘으면 끊고 기본 아이콘). 시험에서 바꿈
int remoteThumbVideoCap = 8 * 1024 * 1024;
/// 서버마다 Range 를 따르는지 (이번 실행 동안 기억 - 앱을 다시 켜면 한 번 다시 봄)
final _rangeOk = <String, bool>{};
/// 시험: Range 기억을 지운다
void resetRemoteThumbRangeForTest() => _rangeOk.clear();
/// 동영상 썸네일을 만들어도 되는 원격인지 (28 지적 · 관리자 지시 10/21):
/// - WebDAV: Range 를 모르는 서버 (206 이 아님) 는 뒤쪽 한 장면에 파일 전체를 받으므로 만들지 않음 (서버마다 기억, 물어보다 실패하면 기억하지 않음)
/// - 시스템 폴더 · 루트 (shz://): 읽기가 늘 파일 전체 복사라 만들지 않음 (d3 판단)
/// - SFTP · SMB · FTP: 그 자리부터 읽음 (FTP 는 REST 를 모르면 받지 않고 실패)
Future<bool> remoteVideoThumbOk(String path) async {
if (isShizuku(path)) return false;
if (!isWebDav(path)) return true;
final d = DavPath.parse(path);
final known = _rangeOk[d.server];
if (known != null) return known;
try {
final client = d.client;
final ok = client is DavClient && await client.rangeSupported(d.rel);
return _rangeOk[d.server] = ok;
} catch (_) {
return false;
}
}
const _images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
/// 원격 썸네일을 받을 종류인지 (그림 - [remoteThumbImageMax] 아래 · 동영상)
bool remoteThumbKind(FileEntry e) => !e.isDir && (isVideoFile(e.path) || (_images.contains(e.ext) && e.size <= remoteThumbImageMax));
DateTime? _meteredAt;
bool _metered = true;
/// 지금 받아도 되는지: 'off' 아니오 · 'always' 예 · 'wifi' (처음 값) 는 데이터 요금제가 아닌 망에서만 (10초 동안 기억)
Future<bool> remoteThumbsAllowedNow(AppController c) async {
switch (c.settings.explorer.remoteThumbs) {
case 'off':
return false;
case 'always':
return true;
}
final now = DateTime.now();
if (_meteredAt == null || now.difference(_meteredAt!) > const Duration(seconds: 10)) {
_metered = await activeMeteredOf();
_meteredAt = now;
}
return !_metered;
}
/// 시험: 망 판단 기억을 지운다
void resetRemoteThumbNetForTest() => _meteredAt = null;
/// FFmpeg 가 이 기기 안 스트리밍 다리 (127.0.0.1, Range) 에서 필요한 부분만 읽어 가로 160 jpg 로. 비밀번호는 명령 줄에 넣지 않는다.
/// MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀)
Future<bool> _make(AppController c, String path, String out) async {
if (c.busy) return false;
final video = isVideoFile(path);
if (video && !await remoteVideoThumbOk(path)) return false;
final url = (await RemoteStreamBridge.get()).url(path, cap: video ? remoteThumbVideoCap : remoteThumbImageMax);
for (final at in isVideoFile(path) ? ['5', '0'] : [null]) {
await c.services.mediaTool.runFfmpeg([
'-hide_banner', '-loglevel', 'error', if (at != null) ...['-ss', at], '-i', url, //
'-frames:v', '1', '-vf', 'scale=160:-2', '-y', out,
]);
if (await File(out).exists() && await File(out).length() > 0) return true;
}
return false;
}
/// 목록 줄의 원격 썸네일 (보이는 동안만 달라고 한다 - 화면에서 사라지면 줄 세운 것은 시작하지 않음)
class RemoteThumb extends StatefulWidget {
final AppController c;
final FileEntry entry;
final Widget fallback;
const RemoteThumb({super.key, required this.c, required this.entry, required this.fallback});
@override
State<RemoteThumb> createState() => _RemoteThumbState();
}
class _RemoteThumbState extends State<RemoteThumb> {
File? _file;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final e = widget.entry;
final key = RemoteThumbs.keyOf(e.path, e.size, e.modified);
final hub = await remoteThumbsOf(widget.c);
final had = hub.cached(key);
if (had != null) {
if (mounted) setState(() => _file = had);
return;
}
final f = await hub.request(e.path, key, wanted: () => mounted);
if (mounted && f != null) setState(() => _file = f);
}
@override
Widget build(BuildContext context) {
final f = _file;
if (f == null) return widget.fallback;
return ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Stack(alignment: Alignment.center, children: [
Image.file(f, width: 56, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => widget.fallback),
if (isVideoFile(widget.entry.path)) const Icon(Icons.play_arrow, size: 18, color: Colors.white70),
// 원격에서 온 것 표시 (작게)
Positioned(right: 1, bottom: 1, child: Icon(Icons.cloud_outlined, size: 10, color: JjColors.textDim)),
]),
);
}
}