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 'theme.dart'; /// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일을 쓰는 쪽 (설정 · 망 · FFmpeg) - 앱에 하나 Future? _hub; Future remoteThumbsOf(AppController c) => _hub ??= () async { final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_rthumbs')); return RemoteThumbs( dir: dir, maxBytes: () => c.settings.explorerRemoteThumbCacheMb * 1024 * 1024, allowed: () => remoteThumbsAllowedNow(c), make: (path, out) => _make(c, path, out), ); }(); /// 시험: 다음에 새로 만든다 void resetRemoteThumbsForTest() => _hub = null; /// 원격 그림은 이보다 크면 받지 않는다 (썸네일 하나에 큰 파일을 통째로 받지 않게) const remoteThumbImageMax = 30 * 1024 * 1024; 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 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 _make(AppController c, String path, String out) async { if (c.busy) return false; final url = (await RemoteStreamBridge.get()).url(path); 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 createState() => _RemoteThumbState(); } class _RemoteThumbState extends State { File? _file; @override void initState() { super.initState(); unawaited(_load()); } Future _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)), ]), ); } }