389 lines
16 KiB
Dart
389 lines
16 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 'theme.dart';
|
|
|
|
/// 다운로드 목록
|
|
class DownloadsPage extends StatefulWidget {
|
|
final DownloadManager d;
|
|
const DownloadsPage({super.key, required this.d});
|
|
|
|
@override
|
|
State<DownloadsPage> createState() => _DownloadsPageState();
|
|
}
|
|
|
|
class _DownloadsPageState extends State<DownloadsPage> {
|
|
final _url = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_url.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _add() {
|
|
final t = widget.d.add(_url.text);
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text(t == null
|
|
? 'YouTube 주소 · 마그넷 링크 · .torrent 주소가 아니거나 이미 받는 중입니다.'
|
|
: '다운로드 추가: ${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: Column(children: [
|
|
Container(
|
|
height: appBarHeight,
|
|
color: JjColors.panel,
|
|
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
|
|
child: Row(children: [
|
|
const AppNavButtons(onDownloadsPage: true),
|
|
const SizedBox(width: 8),
|
|
Text('다운로드 (${d.tasks.length})',
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
const SizedBox(width: 20),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _url,
|
|
style: const TextStyle(fontSize: 13),
|
|
decoration: const InputDecoration(
|
|
isDense: true,
|
|
hintText: 'YouTube 주소 · 마그넷 링크 · .torrent 주소 붙여넣기 (Ctrl+C 만 해도 자동 추가)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onSubmitted: (_) => _add(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(onPressed: _add, child: const Text('추가')),
|
|
const AppActions(),
|
|
]),
|
|
),
|
|
Container(
|
|
color: JjColors.bg,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
child: Row(children: [
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(4),
|
|
onTap: d.tasks.isEmpty ? null : () => allSel ? d.selectNone() : d.selectAll(),
|
|
child: Row(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 ? '${d.selected.length}개 선택' : '전체 선택',
|
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
),
|
|
]),
|
|
),
|
|
const Spacer(),
|
|
if (d.addToEditList != null) ...[
|
|
_btn(Icons.playlist_add, '동영상 추가',
|
|
hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null),
|
|
// 켜면: 다 받는 대로 MKV 만들기 목록에 넣고 이 목록에서는 뺀다
|
|
if (d.setAutoAdd != null)
|
|
Tooltip(
|
|
message: '다 받으면 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),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 8),
|
|
child: Text('완료시 자동 동영상추가', style: TextStyle(fontSize: 12)),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
],
|
|
_btn(Icons.pause, '일시정지', hasSel ? d.pauseSelected : null),
|
|
_btn(Icons.play_arrow, '재개', hasSel ? d.resumeSelected : null),
|
|
_btn(Icons.stop, '취소', hasSel ? d.cancelSelected : null),
|
|
_btn(Icons.delete_outline, '삭제', hasSel ? () => _confirmRemove(context) : null),
|
|
const SizedBox(width: 12),
|
|
_btn(Icons.cleaning_services_outlined, '완료 정리',
|
|
d.tasks.any((t) => !t.unfinished) ? d.cleanupFinished : null),
|
|
]),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: d.tasks.isEmpty
|
|
? const Center(
|
|
child: Text('다운로드가 없습니다.\nYouTube 주소나 마그넷 링크를 복사(Ctrl+C)하면 자동으로 받습니다.',
|
|
textAlign: TextAlign.center, style: 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)),
|
|
),
|
|
);
|
|
|
|
/// 고른 (다 받은) 동영상을 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
|
|
? 'MKV 만들기 목록에 동영상 $n개를 추가했습니다.'
|
|
: '이미 목록에 있는 동영상입니다 (${files.length}개).')));
|
|
}
|
|
|
|
Future<void> _confirmRemove(BuildContext context) async {
|
|
final n = widget.d.selected.length;
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('삭제'),
|
|
content: Text('선택한 $n개를 목록에서 삭제합니다.\n받는 중인 항목은 중지하고 받던 파일도 지웁니다. (완료된 파일은 남습니다)'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('삭제')),
|
|
],
|
|
),
|
|
);
|
|
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});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (label, color) = switch (t.state) {
|
|
DownloadState.queued => ('대기', JjColors.textDim),
|
|
DownloadState.downloading => ('받는 중', JjColors.accent),
|
|
DownloadState.paused => ('일시정지', Colors.amber),
|
|
DownloadState.done => ('완료', JjColors.success),
|
|
DownloadState.failed => ('실패', JjColors.danger),
|
|
DownloadState.cancelled => ('취소됨', 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),
|
|
if (t.state != DownloadState.cancelled) DownloadProgressBar(t: t),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
[
|
|
label,
|
|
if (t.speed.isNotEmpty) t.speed,
|
|
if (t.eta.isNotEmpty) t.eta.contains('중') ? t.eta : '남은 시간 ${t.eta}',
|
|
if (t.error != null) t.error!.split('\n').first,
|
|
].join(' · '),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 11, color: t.error != null ? JjColors.danger : color),
|
|
),
|
|
]),
|
|
),
|
|
IconButton(
|
|
tooltip: '폴더 열기',
|
|
icon: const Icon(Icons.folder_open, size: 18, color: JjColors.textDim),
|
|
onPressed: () {
|
|
if (Platform.isWindows) Process.run('explorer', [t.dir]);
|
|
},
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "받은 크기 / 전체 크기" (다 받았으면 전체 크기만, 모르면 빈 글)
|
|
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;
|
|
final color = switch (t.state) {
|
|
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.queued ? '대기' : '준비 중…')
|
|
: '${(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 ? null : 0),
|
|
minHeight: height,
|
|
backgroundColor: 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(
|
|
title: const Text('종료'),
|
|
content: const Text('다운로드를 종료하시겠습니까?'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('종료')),
|
|
],
|
|
),
|
|
);
|
|
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('받는 중인 다운로드 ${active.length}개'),
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: '종료',
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => _quit(context),
|
|
),
|
|
]),
|
|
content: SizedBox(
|
|
width: 520,
|
|
height: 300,
|
|
child: active.isEmpty
|
|
? const Center(child: Text('받는 중인 다운로드가 없습니다.'))
|
|
: 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: '삭제 (중지하고 받던 파일 삭제)',
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: () => d.remove([t]),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('돌아가기')),
|
|
FilledButton(
|
|
onPressed: () => active.isEmpty ? Navigator.pop(context, true) : _quit(context),
|
|
child: const Text('종료'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|