269 lines
9.8 KiB
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),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|