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 createState() => _DownloadsPageState(); /// 다운로드 목록 화면의 경로 이름 (이미 열려 있으면 그 화면으로 돌아가기 위해) static const routeName = 'downloads'; /// 열려 있는 다운로드 목록 화면 수 static int _open = 0; /// 다운로드 목록으로: 이미 열려 있으면 그 화면으로 돌아가고, 없으면 새로 연다. /// 위쪽 [다운로드 목록] 버튼 · 브라우저의 "목록 보기" · 작업 현황 등 어디서 열어도 같다. static Future 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( settings: const RouteSettings(name: routeName), builder: (_) => DownloadsPage(d: d), )); } } class _DownloadsPageState extends State { 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: LayoutBuilder(builder: (context, box) { final title = Text(trf('다운로드 ({0})', [d.tasks.length]), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)); final field = 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(), ); final add = FilledButton(onPressed: _add, child: Text(tr('추가'))); // F8 (관리자 승인 10/21, 15 찾음): 좁고 글자가 크면 제목이 넓어 주소 칸이 「動画...」 만큼으로 눌렸다 - // 주소 칸이 200 보다 좁아질 때만 주소 칸 · [추가] 를 제목 아래 한 줄로 (넓으면 예전 한 줄 그대로) final scaler = MediaQuery.textScalerOf(context); double textW(String t, double size, [FontWeight? w]) => (TextPainter( text: TextSpan(text: t, style: DefaultTextStyle.of(context).style.copyWith(fontSize: size, fontWeight: w)), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1) ..layout()) .width; final gap = isCompact(context) ? 8.0 : 20.0; final need = textW(trf('다운로드 ({0})', [d.tasks.length]), 16, FontWeight.w600) + gap + 200 + 8 + textW(tr('추가'), 14) + 48; // 한 줄 막대 (높이가 정해짐) 에서는 늘 한 줄 - 모자라면 막대가 스스로 두 줄 배치로 바뀐다 (AppTopBar) if (box.maxWidth >= need || box.maxHeight.isFinite) { return Row(children: [title, SizedBox(width: gap), Expanded(child: field), const SizedBox(width: 8), add]); } return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ title, const SizedBox(height: 6), Row(children: [Expanded(child: field), const SizedBox(width: 8), add]), ]); }), ), 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 _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 _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 _confirmRemove(BuildContext context) async { final n = widget.d.selected.length; final ok = await showDialog( 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 _showDetail(BuildContext context, DownloadTask t) => showDialog( 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 confirmExit(BuildContext context, DownloadManager d) async { if (d.activeCount == 0) return true; final go = await showDialog( context: context, barrierDismissible: false, builder: (_) => _ExitDialog(d: d), ); return go ?? false; } class _ExitDialog extends StatelessWidget { final DownloadManager d; const _ExitDialog({required this.d}); Future _quit(BuildContext context) async { final ok = await showDialog( 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('종료')), ), ], ); }, ); }