160 lines
6.2 KiB
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.explorerRemoteThumbCacheMb * 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.explorerRemoteThumbs) {
|
|
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)),
|
|
]),
|
|
);
|
|
}
|
|
}
|