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 openDlna(BuildContext context, AppController c) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DlnaPage(c: c))); class DlnaPage extends StatefulWidget { final AppController c; /// 시험: 찾기 · 읽기를 바꿔 끼움 final Future> Function()? discover; final Future Function(DlnaServer s, String id, int start)? browse; const DlnaPage({super.key, required this.c, this.discover, this.browse}); @override State createState() => _DlnaPageState(); } /// [dlnaBrowse] 의 결과 (시험에서 만들 수 있게) typedef DlnaResult = ({List entries, int total}); class _DlnaPageState extends State { List? _servers; bool _finding = false; DlnaServer? _server; /// 들어간 폴더들 (id, 이름) - 맨 앞은 뿌리 final _path = <(String, String)>[]; List _entries = const []; int _total = 0; bool _loading = false; String? _error; @override void initState() { super.initState(); unawaited(_find()); } Future _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 _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 _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 _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 _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( 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 _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: TextStyle(fontSize: 12, color: JjColors.textDim)), ), if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: 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: 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 ? JjColors.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), ); }, ); } }