JJ_MKVMaker/lib/ui/downloads_page.dart

540 lines
25 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import '../app/download_manager.dart';
import '../core/download_detect.dart';
import '../services/downloader.dart';
import 'app_actions.dart';
import 'confirm.dart';
import 'theme.dart';
import '../l10n/tr.dart';
/// 다운로드 목록
class DownloadsPage extends StatefulWidget {
final DownloadManager d;
const DownloadsPage({super.key, required this.d});
@override
State<DownloadsPage> createState() => _DownloadsPageState();
/// 다운로드 목록 화면의 경로 이름 (이미 열려 있으면 그 화면으로 돌아가기 위해)
static const routeName = 'downloads';
/// 열려 있는 다운로드 목록 화면 수
static int _open = 0;
/// 다운로드 목록으로: 이미 열려 있으면 그 화면으로 돌아가고, 없으면 새로 연다.
/// 위쪽 [다운로드 목록] 버튼 · 브라우저의 "목록 보기" · 작업 현황 등 어디서 열어도 같다.
static Future<void> open(NavigatorState nav, DownloadManager d) async {
if (_open > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == routeName) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: const RouteSettings(name: routeName),
builder: (_) => DownloadsPage(d: d),
));
}
}
class _DownloadsPageState extends State<DownloadsPage> {
final _url = TextEditingController();
@override
void initState() {
super.initState();
DownloadsPage._open++;
}
@override
void dispose() {
DownloadsPage._open--;
_url.dispose();
super.dispose();
}
void _add() {
final text = _url.text.trim();
var t = widget.d.add(text);
// 26: 직접 넣은 주소는 YouTube 가 아니어도 yt-dlp 로 (지원하는 동영상 사이트). 클립보드 감시는 예전처럼 YouTube · 토렌트만
if (t == null) {
final url = RegExp(r'https?://[^\s<>"]+', caseSensitive: false).firstMatch(text)?[0];
if (url != null) t = widget.d.addPage(url);
}
// 앞의 알림을 기다리지 않고 이번 결과를 바로
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(t == null
? tr('주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.')
: trf('다운로드 추가: {0}', [t.source]))));
if (t != null) _url.clear();
}
@override
Widget build(BuildContext context) {
final d = widget.d;
return ListenableBuilder(
listenable: d,
builder: (context, _) {
final hasSel = d.selected.isNotEmpty;
final allSel = d.tasks.isNotEmpty && d.selected.length == d.tasks.length;
return Scaffold(
body: SwipeNav(
current: 'downloads',
child: Column(children: [
// 200: 폰 가로에서 버튼이 폭을 다 차지해 주소 칸이 눌려 주소가 보이지 않았다 - 모자라면 두 줄 (아래 줄을 주소 칸이 다 씀)
AppTopBar(
minMiddleWidth: 420,
nav: const AppNavButtons(onDownloadsPage: true),
actions: const AppActions(),
middle: Row(children: [
Text(trf('다운로드 ({0})', [d.tasks.length]),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
SizedBox(width: isCompact(context) ? 8 : 20),
Expanded(
child: TextField(
controller: _url,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
// Android 는 클립보드를 감시하지 않는다
hintText: Platform.isAndroid
? tr('동영상 주소 (YouTube 등) · 마그넷 링크 · .torrent 주소 붙여넣기')
: tr('동영상 주소 (YouTube 등) · 마그넷 링크 · .torrent 주소 붙여넣기 (YouTube · 토렌트는 Ctrl+C 만 해도 자동 추가)'),
border: OutlineInputBorder(),
),
onSubmitted: (_) => _add(),
),
),
const SizedBox(width: 8),
FilledButton(onPressed: _add, child: Text(tr('추가'))),
]),
),
Container(
color: JjColors.bg,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
// 246: 좁으면 (폰 가로 등) 버튼들을 다음 줄로 - 예전에는 옆으로 미는 줄이라 앞쪽 글이 잘려 "전체 선택" 옆에 겹쳐 보였다
child: Wrap(alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, children: [
InkWell(
borderRadius: BorderRadius.circular(4),
onTap: d.tasks.isEmpty ? null : () => allSel ? d.selectNone() : d.selectAll(),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox(
value: allSel ? true : (hasSel ? null : false),
tristate: true,
onChanged: d.tasks.isEmpty ? null : (_) => allSel ? d.selectNone() : d.selectAll(),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(hasSel ? trf('{0}개 선택', [d.selected.length]) : tr('전체 선택'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
),
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: [
if (d.addToEditList != null) ...[
_btn(Icons.playlist_add, tr('동영상 추가'),
hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null),
// 켜면: 다 받는 대로 MKV 만들기 목록에 넣고 이 목록에서는 뺀다
if (d.setAutoAdd != null)
Tooltip(
message: tr('다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)'),
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => d.setAutoAdd!(!d.settings().addFinishedDownloads),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox(
visualDensity: VisualDensity.compact,
value: d.settings().addFinishedDownloads,
onChanged: (v) => d.setAutoAdd!(v ?? false),
),
Padding(
padding: EdgeInsets.only(right: 8),
child: Text(tr('완료 시 자동 동영상 추가'), style: TextStyle(fontSize: 12)),
),
]),
),
),
],
_btn(Icons.pause, tr('일시정지'), hasSel ? d.pauseSelected : null),
_btn(Icons.play_arrow, tr('재개'), hasSel ? d.resumeSelected : null),
// 44: 일시정지 · 재개와 떨어뜨리고 (잘못 누르지 않게), 누르면 확인
const SizedBox(width: 16),
_btn(Icons.stop, tr('다운로드 취소'), hasSel ? () => _confirmCancel(context) : null),
_btn(Icons.delete_outline, tr('삭제'), hasSel ? () => _confirmRemove(context) : null),
const SizedBox(width: 12),
_btn(Icons.cleaning_services_outlined, tr('완료 정리'),
d.tasks.any((t) => !t.unfinished) ? d.cleanupFinished : null),
]),
]),
),
const Divider(height: 1),
Expanded(
child: d.tasks.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
Platform.isAndroid
? tr('다운로드가 없습니다.\nYouTube 주소 · 마그넷 링크를 위 칸에 붙여 넣거나, 웹 브라우저에서 [다운로드] 를 누르세요.')
: tr('다운로드가 없습니다.\nYouTube 주소나 마그넷 링크를 복사(Ctrl+C)하면 자동으로 받습니다.'),
textAlign: TextAlign.center,
style: const TextStyle(color: JjColors.textDim))))
: ListView.builder(
itemCount: d.tasks.length,
itemBuilder: (_, i) => _DownloadRow(d: d, t: d.tasks[i]),
),
),
]),
),
);
},
);
}
Widget _btn(IconData icon, String label, VoidCallback? onTap) => Padding(
padding: const EdgeInsets.only(left: 6),
child: OutlinedButton.icon(
onPressed: onTap,
icon: Icon(icon, size: 16),
label: Text(label, style: const TextStyle(fontSize: 12)),
),
);
/// 44: 다운로드 취소 전에 묻는다 (잠깐 멈추려면 일시정지)
Future<void> _confirmCancel(BuildContext context) async {
final n = widget.d.selected.length;
final ok = await confirmAction(
context,
title: tr('다운로드 취소'),
body: trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [n]),
ok: tr('다운로드 취소'),
// 185: 바로 옆 [다운로드 취소] 와 헷갈리지 않게 - 취소하지 않는 쪽은 [계속 받기]
cancel: tr('계속 받기'),
);
if (ok) await widget.d.cancelSelected();
}
/// 고른 (다 받은) 동영상을 MKV 만들기의 동영상 목록에 추가
Future<void> _addToEditList(BuildContext context) async {
final files = widget.d.selectedVideoFiles;
final m = ScaffoldMessenger.of(context);
final n = await widget.d.addToEditList!(files);
m.showSnackBar(SnackBar(
content: Text(n > 0
? trf('MKV 만들기 목록에 동영상 {0}개를 추가했습니다.', [n])
: trf('이미 목록에 있는 동영상입니다 ({0}개).', [files.length]))));
}
Future<void> _confirmRemove(BuildContext context) async {
final n = widget.d.selected.length;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('삭제')),
content: Text(trf('선택한 {0}개를 목록에서 삭제합니다.\n받는 중인 항목은 중지하고 받던 파일도 지웁니다. (완료된 파일은 남습니다)', [n])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('삭제'))),
],
),
);
if (ok == true) await widget.d.removeSelected();
}
}
class _DownloadRow extends StatelessWidget {
final DownloadManager d;
final DownloadTask t;
const _DownloadRow({required this.d, required this.t});
static final _small = TextButton.styleFrom(
visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28));
/// 실패 이유 (사람 말). 263: DNS 서버에 닿지 않아 실패했으면 그 이유 (yt-dlp 원문은 "프록시 터널 실패" 뿐이라 알 수 없었다)
static String _why(DownloadTask t) => switch (t.extra['dnsServer']) {
final String s => trf('DNS 서버 ({0}) 에 닿지 않아 받지 못했습니다', [s]),
_ => friendlyDownloadError(t.error ?? ''),
};
/// 136: 실패 원문 (yt-dlp · aria2 가 남긴 그대로 - 복사해 물어볼 수 있게)
static Future<void> _showDetail(BuildContext context, DownloadTask t) => showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(_why(t)),
content: SizedBox(
width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
// 서명 · 키가 든 주소 부분은 가린다
SelectableText(redactUrlSecrets(t.source), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8),
SelectableText(redactUrlSecrets(t.error ?? ''), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
]),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
),
);
@override
Widget build(BuildContext context) {
final (label, color) = switch (t.state) {
DownloadState.queued => (tr('대기 중'), JjColors.textDim),
DownloadState.downloading when t.extra['waiting'] == true => (tr('대기 중'), JjColors.textDim),
DownloadState.downloading => (tr('받는 중'), JjColors.accent),
DownloadState.paused => (tr('일시정지'), Colors.amber),
DownloadState.done => (tr('완료'), JjColors.success),
DownloadState.failed => (tr('실패'), JjColors.danger),
DownloadState.cancelled => (tr('취소됨'), JjColors.textDim),
};
return InkWell(
onTap: () => d.toggle(t),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: d.selected.contains(t.id) ? JjColors.accent.withValues(alpha: 0.08) : null,
border: const Border(bottom: BorderSide(color: JjColors.border)),
),
child: Row(children: [
Checkbox(value: d.selected.contains(t.id), onChanged: (_) => d.toggle(t)),
Icon(t.kind == DownloadKind.video ? Icons.smart_display_outlined : Icons.cloud_download_outlined,
color: JjColors.textDim),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4),
// 136: 실패하면 진행 막대 ("준비 중…") 를 지우고 실패 · 이유 (사람 말) · [자세히] · [다시 시도]
if (t.state != DownloadState.cancelled && t.state != DownloadState.failed) DownloadProgressBar(t: t),
const SizedBox(height: 4),
// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 - 이유 후보와 [다시 시도] (아무 표시 없이 "받는 중" 이던 것)
if (t.state == DownloadState.downloading && t.extra['stalled'] == true)
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text(
'${trf('트래커 · 피어에 닿지 않습니다 ({0}초째)', [((DateTime.now().millisecondsSinceEpoch - ((t.extra['stallSince'] as int?) ?? 0)) / 1000).round()])} · '
'${trf('이유 후보: {0}', [[for (final r in (t.extra['stallReasons'] as List?) ?? const []) stallReasonText('$r')].join(' · ')])}',
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
if (((t.extra['stallReasons'] as List?) ?? const []).contains('dns') && d.useSystemDns != null)
TextButton(style: _small, onPressed: () => d.useSystemDns!(), child: Text(tr('시스템 DNS 로 바꾸기'))),
TextButton(style: _small, onPressed: () => d.retryStalled(t), child: Text(tr('다시 시도'))),
])
else if (t.state == DownloadState.failed)
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)),
if ((t.error ?? '').trim().isNotEmpty)
TextButton(
style: _small,
onPressed: () => _showDetail(context, t),
child: Text(tr('자세히')),
),
// 263: 아직 DNS 강제면 [시스템 DNS 로 바꾸기] (바꾼 뒤 [다시 시도] 는 사용자가 - 몰래 다시 받지 않음)
if (t.extra['dnsServer'] != null && d.useSystemDns != null && d.settings().dnsMode != 'system')
TextButton(style: _small, onPressed: () => d.useSystemDns!(), child: Text(tr('시스템 DNS 로 바꾸기'))),
TextButton(style: _small, onPressed: () => d.resume([t]), child: Text(tr('다시 시도'))),
])
else
Text(
[
label,
if (t.speed.isNotEmpty) t.speed,
if (t.eta.isNotEmpty) t.eta.contains(RegExp(r'\d')) ? trf('남은 시간 {0}', [t.eta]) : t.eta, // 숫자가 없으면 "합치는 중" 같은 단계
if (t.error != null) redactUrlSecrets(t.error!.split('\n').first),
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: t.error != null ? JjColors.danger : color),
),
]),
),
IconButton(
tooltip: tr('폴더 열기'),
icon: const Icon(Icons.folder_open, size: 18, color: JjColors.textDim),
onPressed: () {
if (Platform.isWindows) {
Process.run('explorer', [t.dir]);
} else {
// Android: 파일 앱으로
AppScope.maybeOf(context)?.controller.services.shell.revealFile(t.dir);
}
},
),
]),
),
);
}
}
/// 멈춘 마그넷 · 토렌트의 이유 후보 글 (Aria2Backend 가 정한 이름)
String stallReasonText(String code) => switch (code) {
'ipv6only' => tr('인터넷 IPv4 가 없음 (IPv6 만)'),
'dns' => tr('DNS 강제 (환경 설정 > 네트워크 · DNS)'),
_ => tr('방화벽 · 공유기가 토렌트를 막음'),
};
/// "받은 크기 / 전체 크기" (다 받았으면 전체 크기만, 모르면 빈 글)
String downloadSizeText(DownloadTask t) {
final got = t.receivedBytes, all = t.totalBytes;
if (t.state == DownloadState.done) {
final n = all ?? got;
return n == null || n <= 0 ? '' : formatBytes(n);
}
if (all != null && all > 0) return '${formatBytes(got ?? 0)} / ${formatBytes(all)}';
return got != null && got > 0 ? formatBytes(got) : '';
}
/// 퍼센트가 보이는 진행 막대
class DownloadProgressBar extends StatelessWidget {
final DownloadTask t;
final double height;
const DownloadProgressBar({super.key, required this.t, this.height = 18});
@override
Widget build(BuildContext context) {
final done = t.state == DownloadState.done;
final value = done ? 1.0 : t.progress;
// 관리자 지시 (10/20, 28 확인): 멈춘 마그넷 · 토렌트 (줄의 주황 "트래커 · 피어에 닿지 않습니다") 는 막대를 움직이지 않고
// 주황으로 "멈춤" - 움직이는 "준비 중…" 이면 받는 중인 것처럼 보였다
final stalled = t.state == DownloadState.downloading && t.extra['stalled'] == true;
final color = switch (t.state) {
_ when stalled => Colors.orangeAccent,
DownloadState.done => JjColors.success,
DownloadState.failed => JjColors.danger,
DownloadState.paused => Colors.amber,
DownloadState.cancelled => JjColors.textDim,
_ => JjColors.accent,
};
// "45.3% · 166MB / 367MB" (크기를 모르면 퍼센트만)
final size = downloadSizeText(t);
final text = value == null
? (t.state == DownloadState.failed
? tr('실패') // 136: 실패한 것에 "준비 중…" 이 남지 않게 (작은 다운로드 상자 등)
: t.state == DownloadState.queued || t.extra['waiting'] == true
? tr('대기 중')
: stalled
// 관리자 지시 (10/20, 28 지적): 영어 "Stopped" 는 일시 정지 · 취소로 읽혀 따로 열쇠 ("Stalled")
? tr('멈춤 (시작 안 됨)')
: tr('준비 중…'))
: '${(value * 100).toStringAsFixed(1)}%${size.isEmpty ? '' : ' · $size'}';
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
height: height,
child: Stack(fit: StackFit.expand, children: [
LinearProgressIndicator(
// 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대
value: value ?? (t.state == DownloadState.downloading && !stalled ? null : 0),
minHeight: height,
// 멈춘 줄은 빈 막대도 주황 기운으로 (회색 빈 막대면 그냥 시작 전처럼 보임)
backgroundColor: stalled ? Colors.orangeAccent.withValues(alpha: 0.28) : JjColors.panelHigh,
color: color.withValues(alpha: 0.85),
),
Center(
child: Text(
text,
style: TextStyle(
fontSize: height * 0.62,
height: 1,
fontWeight: FontWeight.w600,
color: Colors.white,
shadows: const [Shadow(blurRadius: 2, color: Colors.black)],
),
),
),
]),
),
);
}
}
/// 종료 확인. 받는 중인 다운로드가 있으면 목록을 보여 주고 한 번 더 확인한다.
Future<bool> confirmExit(BuildContext context, DownloadManager d) async {
if (d.activeCount == 0) return true;
final go = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _ExitDialog(d: d),
);
return go ?? false;
}
class _ExitDialog extends StatelessWidget {
final DownloadManager d;
const _ExitDialog({required this.d});
Future<void> _quit(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('종료')),
content: Text(tr('다운로드를 종료하시겠습니까?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('종료'))),
],
),
);
if (ok == true && context.mounted) Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: d,
builder: (context, _) {
final active = d.tasks.where((t) => t.unfinished).toList();
return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(24, 16, 8, 0),
title: Row(children: [
Text(trf('받는 중인 다운로드 {0}개', [active.length])),
const Spacer(),
IconButton(
tooltip: tr('종료'),
icon: const Icon(Icons.close),
onPressed: () => _quit(context),
),
]),
content: SizedBox(
width: 520,
height: 300,
child: active.isEmpty
? Center(child: Text(tr('받는 중인 다운로드가 없습니다.')))
: ListView(children: [
for (final t in active)
ListTile(
dense: true,
leading: Icon(t.kind == DownloadKind.video
? Icons.smart_display_outlined
: Icons.cloud_download_outlined),
title: Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: DownloadProgressBar(t: t, height: 14),
),
trailing: IconButton(
tooltip: tr('삭제 (중지하고 받던 파일 삭제)'),
icon: const Icon(Icons.delete_outline),
onPressed: () => d.remove([t]),
),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(tr('돌아가기'))),
FilledButton(
onPressed: () => active.isEmpty ? Navigator.pop(context, true) : _quit(context),
child: Text(tr('종료')),
),
],
);
},
);
}