JJ_MKVMaker/lib/ui/dlna_page.dart

269 lines
9.8 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/dlna.dart';
import '../core/file_ops.dart' show formatSize;
import '../core/reader_sources.dart';
import '../l10n/tr.dart';
import 'player_page.dart';
import 'reader_page.dart' show openReader;
import 'theme.dart';
/// X-plore 비교 5-2 (사용자 결정 10/21 "DLNA 보기만"): 같은 Wi-Fi 의 미디어 서버 보기 · 재생 (서버 · 올리기 · 지우기 없음)
Future<void> openDlna(BuildContext context, AppController c) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => DlnaPage(c: c)));
class DlnaPage extends StatefulWidget {
final AppController c;
/// 시험: 찾기 · 읽기를 바꿔 끼움
final Future<List<DlnaServer>> Function()? discover;
final Future<DlnaResult> Function(DlnaServer s, String id, int start)? browse;
const DlnaPage({super.key, required this.c, this.discover, this.browse});
@override
State<DlnaPage> createState() => _DlnaPageState();
}
/// [dlnaBrowse] 의 결과 (시험에서 만들 수 있게)
typedef DlnaResult = ({List<DlnaEntry> entries, int total});
class _DlnaPageState extends State<DlnaPage> {
List<DlnaServer>? _servers;
bool _finding = false;
DlnaServer? _server;
/// 들어간 폴더들 (id, 이름) - 맨 앞은 뿌리
final _path = <(String, String)>[];
List<DlnaEntry> _entries = const [];
int _total = 0;
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
unawaited(_find());
}
Future<void> _find() async {
setState(() {
_finding = true;
_error = null;
});
try {
final list = await (widget.discover ?? discoverDlna)();
if (mounted) setState(() => _servers = list);
} catch (e) {
if (mounted) setState(() => _error = trf('찾지 못했습니다: {0}', [e]));
} finally {
if (mounted) setState(() => _finding = false);
}
}
Future<DlnaResult> _browse(DlnaServer s, String id, int start) async {
if (widget.browse != null) return widget.browse!(s, id, start);
final page = await dlnaBrowse(s, objectId: id, start: start);
return (entries: page.entries, total: page.total);
}
Future<void> _open(DlnaServer s, String id, String name, {bool push = true}) async {
setState(() {
_server = s;
if (push) _path.add((id, name));
_entries = const [];
_loading = true;
_error = null;
});
try {
final r = await _browse(s, id, 0);
if (!mounted) return;
setState(() {
_entries = r.entries;
_total = r.total;
});
} catch (e) {
if (mounted) setState(() => _error = trf('서버에서 목록을 읽지 못했습니다: {0}', [e]));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _more() async {
final s = _server!;
setState(() => _loading = true);
try {
final r = await _browse(s, _path.last.$1, _entries.length);
if (mounted) setState(() => _entries = [..._entries, ...r.entries]);
} catch (e) {
if (mounted) setState(() => _error = trf('서버에서 목록을 읽지 못했습니다: {0}', [e]));
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _up() {
if (_path.length > 1) {
_path.removeLast();
unawaited(_open(_server!, _path.last.$1, _path.last.$2, push: false));
} else {
setState(() {
_server = null;
_path.clear();
_entries = const [];
});
}
}
Future<void> _tap(DlnaEntry e) async {
if (e.isContainer) return _open(_server!, e.id, e.title);
final url = e.url;
if (url == null) {
_snack(tr('이 항목은 같은 망 밖의 주소라 열지 않습니다'));
return;
}
switch (e.kind) {
case 'video' || 'audio':
// 같은 폴더의 같은 종류를 차례대로 (다음 화로 넘어가게)
final list = [for (final x in _entries) if (!x.isContainer && x.url != null && x.kind == e.kind) x];
final create = widget.c.services.createMediaPlayer;
if (create == null) return;
await Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => PlayerPage(c: widget.c, player: create(), files: [for (final x in list) x.url!], start: list.indexOf(e))));
case 'image':
await _viewImage(e);
default:
_snack(tr('볼 수 없는 종류입니다'));
}
}
/// 그림은 임시로 받아서 보기 화면으로 (50MB 까지)
Future<void> _viewImage(DlnaEntry e) async {
try {
final dir = Directory(p.join(await widget.c.services.storage.tempDirectory(), 'jj_dlna'));
await dir.create(recursive: true);
final ext = switch (e.mime) { 'image/png' => '.png', 'image/gif' => '.gif', 'image/webp' => '.webp', _ => '.jpg' };
final out = File(p.join(dir.path, 'view$ext'));
final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
try {
final res = await (await client.getUrl(Uri.parse(e.url!))).close();
if (res.statusCode >= 300) throw HttpException('HTTP ${res.statusCode}');
final sink = out.openWrite();
var n = 0;
await for (final chunk in res) {
n += chunk.length;
if (n > 50 * 1024 * 1024) throw const HttpException('too big');
sink.add(chunk);
}
await sink.close();
} finally {
client.close(force: true);
}
if (!mounted) return;
await openReader(context, widget.c, ImageFilesSource([out.path], tempDir: dir.path, title: e.title));
} catch (err) {
_snack(trf('열 수 없습니다: {0}', [err]));
}
}
void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t)));
IconData _icon(DlnaEntry e) => e.isContainer
? Icons.folder
: switch (e.kind) { 'video' => Icons.movie_outlined, 'audio' => Icons.audiotrack_outlined, 'image' => Icons.image_outlined, _ => Icons.insert_drive_file_outlined };
String _d(Duration d) => [if (d.inHours > 0) '${d.inHours}', (d.inMinutes % 60).toString().padLeft(d.inHours > 0 ? 2 : 1, '0'), (d.inSeconds % 60).toString().padLeft(2, '0')].join(':');
@override
Widget build(BuildContext context) {
final s = _server;
return PopScope(
canPop: s == null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _up();
},
child: Scaffold(
appBar: AppBar(
title: Text(s == null ? tr('미디어 서버 (DLNA)') : [s.name, ..._path.skip(1).map((x) => x.$2)].join(' / '),
maxLines: 1, overflow: TextOverflow.ellipsis),
leading: s == null ? null : IconButton(tooltip: tr('뒤로'), icon: const Icon(Icons.arrow_back), onPressed: _up),
actions: [
if (s == null)
IconButton(tooltip: tr('다시 찾기'), icon: const Icon(Icons.refresh), onPressed: _finding ? null : _find),
],
),
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(tr('보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))),
if (s == null) Expanded(child: _serverList()) else Expanded(child: _entryList()),
]),
),
);
}
Widget _serverList() {
final list = _servers;
if (_finding && (list == null || list.isEmpty)) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text(tr('같은 망의 미디어 서버를 찾는 중 …')),
]),
);
}
if (list == null || list.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(tr('미디어 서버를 찾지 못했습니다. Wi-Fi · 유선에 연결돼 있는지, 서버 (NAS 의 미디어 서버 등) 가 켜져 있는지 확인하세요'),
textAlign: TextAlign.center),
),
);
}
return ListView(children: [
for (final x in list)
ListTile(
leading: const Icon(Icons.dns_outlined),
title: Text(x.name),
subtitle: Text(x.host, style: const TextStyle(color: JjColors.textDim)),
onTap: () => _open(x, '0', x.name),
),
]);
}
Widget _entryList() {
if (_loading && _entries.isEmpty) return const Center(child: CircularProgressIndicator());
if (!_loading && _entries.isEmpty && _error == null) return Center(child: Text(tr('비어 있음')));
return ListView.builder(
itemCount: _entries.length + (_entries.length < _total ? 1 : 0),
itemBuilder: (_, i) {
if (i == _entries.length) {
return Padding(
padding: const EdgeInsets.all(8),
child: OutlinedButton(onPressed: _loading ? null : _more, child: Text(trf('더 보기 ({0} / {1})', [_entries.length, _total]))),
);
}
final e = _entries[i];
return ListTile(
dense: true,
leading: Icon(_icon(e), color: e.isContainer ? Colors.amber : JjColors.textDim),
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: e.isContainer
? (e.childCount == null ? null : Text(trf('{0}개', [e.childCount!])))
: Text([if (e.size != null) formatSize(e.size!), if (e.duration != null) _d(e.duration!), if (e.url == null) tr('열 수 없음')].join(' · ')),
onTap: () => _tap(e),
);
},
);
}
}