499 lines
21 KiB
Dart
499 lines
21 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../data/models.dart';
|
|
import '../state/app_state.dart';
|
|
import 'download_service.dart';
|
|
import 'player_page.dart';
|
|
|
|
/// Preserve the actual failure instead of suggesting an unrelated source fix.
|
|
String downloadErrorMessage(Object error) => switch (error) {
|
|
PlatformException(:final message, :final code) =>
|
|
message?.trim().isNotEmpty == true ? message! : code,
|
|
StateError(:final message) => message.toString(),
|
|
UnsupportedError(:final message) => message ?? error.toString(),
|
|
_ => error.toString(),
|
|
};
|
|
|
|
/// Renders the service snapshot only; no estimated progress, speed or ETA.
|
|
class DownloadTransferDetails extends StatelessWidget {
|
|
const DownloadTransferDetails({super.key, required this.entry});
|
|
final DownloadEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final status = switch (entry.taskStatus) {
|
|
TaskStatus.enqueued =>
|
|
entry.task.requiresWiFi
|
|
? tr(context, 'Queued · Wi-Fi required', 'در صف · نیازمند وایفای')
|
|
: tr(context, 'Queued', 'در صف'),
|
|
TaskStatus.running => tr(context, 'Downloading', 'در حال دانلود'),
|
|
TaskStatus.waitingToRetry => tr(
|
|
context,
|
|
'Waiting to retry',
|
|
'در انتظار تلاش دوباره',
|
|
),
|
|
TaskStatus.paused => tr(context, 'Paused', 'متوقفشده'),
|
|
TaskStatus.complete => tr(context, 'Downloaded', 'دانلودشده'),
|
|
TaskStatus.failed => tr(context, 'Failed', 'ناموفق'),
|
|
TaskStatus.canceled => tr(context, 'Canceled', 'لغوشده'),
|
|
TaskStatus.notFound => tr(context, 'Source not found', 'منبع پیدا نشد'),
|
|
};
|
|
final measured =
|
|
entry.totalBytes > 0 || entry.progress > 0 || entry.isComplete;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(status),
|
|
const SizedBox(height: 10),
|
|
LinearProgressIndicator(
|
|
value: measured
|
|
? entry.progress
|
|
: entry.isActive
|
|
? null
|
|
: 0,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
[
|
|
if (measured) '${(entry.progress * 100).round()}%',
|
|
if (entry.receivedBytes > 0 || entry.totalBytes > 0)
|
|
'${_bytes(entry.receivedBytes)} / ${entry.totalBytes > 0 ? _bytes(entry.totalBytes) : '—'}',
|
|
if (entry.taskStatus == TaskStatus.running && entry.speed > 0)
|
|
'${_bytes(entry.speed.round())}/s',
|
|
].join(' · '),
|
|
textDirection: TextDirection.ltr,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
if (entry.error.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
entry.error,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Labeled primary operations; pending native acknowledgments disable repeats.
|
|
class DownloadControls extends StatefulWidget {
|
|
const DownloadControls({
|
|
super.key,
|
|
required this.entry,
|
|
required this.onPause,
|
|
required this.onResume,
|
|
required this.onRetry,
|
|
required this.onCancel,
|
|
});
|
|
final DownloadEntry entry;
|
|
final Future<void> Function() onPause, onResume, onRetry, onCancel;
|
|
|
|
@override
|
|
State<DownloadControls> createState() => _DownloadControlsState();
|
|
}
|
|
|
|
class _DownloadControlsState extends State<DownloadControls> {
|
|
bool _busy = false;
|
|
|
|
Future<void> _run(Future<void> Function() operation) async {
|
|
if (_busy) return;
|
|
setState(() => _busy = true);
|
|
try {
|
|
await operation();
|
|
} catch (error) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(downloadErrorMessage(error))));
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
if (widget.entry.canPause)
|
|
FilledButton.tonalIcon(
|
|
onPressed: _busy ? null : () => _run(widget.onPause),
|
|
icon: const Icon(Icons.pause),
|
|
label: Text(tr(context, 'Pause', 'توقف')),
|
|
),
|
|
if (widget.entry.canResume)
|
|
FilledButton.tonalIcon(
|
|
onPressed: _busy ? null : () => _run(widget.onResume),
|
|
icon: const Icon(Icons.play_arrow),
|
|
label: Text(tr(context, 'Resume', 'ادامه')),
|
|
),
|
|
if (!widget.entry.isActive &&
|
|
!widget.entry.canResume &&
|
|
!widget.entry.isComplete)
|
|
FilledButton.tonalIcon(
|
|
onPressed: _busy ? null : () => _run(widget.onRetry),
|
|
icon: const Icon(Icons.refresh),
|
|
label: Text(tr(context, 'Retry', 'تلاش دوباره')),
|
|
),
|
|
if (widget.entry.isActive || widget.entry.canResume)
|
|
OutlinedButton.icon(
|
|
onPressed: _busy ? null : () => _run(widget.onCancel),
|
|
icon: const Icon(Icons.cancel_outlined),
|
|
label: Text(tr(context, 'Cancel', 'لغو')),
|
|
),
|
|
if (_busy)
|
|
const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Optional handoff stays secondary to Reelbox's own queue and controls.
|
|
class ExternalDownloadMenu extends StatelessWidget {
|
|
const ExternalDownloadMenu({super.key, required this.onSelected});
|
|
final ValueChanged<String> onSelected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => PopupMenuButton<String>(
|
|
tooltip: tr(context, 'More download options', 'گزینههای بیشتر دانلود'),
|
|
icon: const Icon(Icons.more_vert),
|
|
onSelected: onSelected,
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(
|
|
value: 'ask',
|
|
child: Text(
|
|
tr(context, 'Choose external downloader…', 'انتخاب دانلودر بیرونی…'),
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'ADM',
|
|
child: Text(
|
|
tr(context, 'External downloader: ADM', 'دانلودر بیرونی: ADM'),
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'IDM',
|
|
child: Text(
|
|
tr(context, 'External downloader: IDM', 'دانلودر بیرونی: IDM'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _bytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
|
|
class DownloadsPage extends StatefulWidget {
|
|
const DownloadsPage({super.key});
|
|
@override
|
|
State<DownloadsPage> createState() => _DownloadsPageState();
|
|
}
|
|
|
|
class _DownloadsPageState extends State<DownloadsPage> {
|
|
final service = DownloadService.instance;
|
|
Future<void>? startup;
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
service.bindSettings(AppScope.read(context));
|
|
startup ??= service.initialize();
|
|
}
|
|
|
|
Future<void> action(Future<void> operation) async {
|
|
try {
|
|
await operation;
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(e.toString())));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Scaffold(
|
|
appBar: AppBar(title: Text(tr(context, 'Downloads', 'دانلودها'))),
|
|
body: !service.supported
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
tr(
|
|
context,
|
|
'Background downloads and offline playback are available in the Android app.',
|
|
'دانلود پسزمینه و پخش آفلاین در برنامه اندروید در دسترس است.',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
)
|
|
: FutureBuilder<void>(
|
|
future: startup,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Text(
|
|
service.initializationError ??
|
|
'Download initialization failed.',
|
|
),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => setState(() {
|
|
startup = service.initialize();
|
|
}),
|
|
child: Text(tr(context, 'Retry', 'تلاش دوباره')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return AnimatedBuilder(
|
|
animation: service,
|
|
builder: (context, _) {
|
|
final items = service.items;
|
|
if (items.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.download_for_offline_outlined,
|
|
size: 52,
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
tr(
|
|
context,
|
|
'Your offline library starts here',
|
|
'کتابخانه آفلاین شما از اینجا شروع میشود',
|
|
),
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
tr(
|
|
context,
|
|
'Choose a quality on a movie or episode and tap Download.',
|
|
'در صفحه فیلم یا قسمت، کیفیت را انتخاب کنید و دانلود را بزنید.',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return RefreshIndicator(
|
|
onRefresh: () => action(service.refresh()),
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: items.length,
|
|
itemBuilder: (_, i) {
|
|
final entry = items[i];
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 14),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entry.title,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium,
|
|
),
|
|
if (entry.episodeTitle.isNotEmpty)
|
|
Text(entry.episodeTitle),
|
|
const SizedBox(height: 6),
|
|
if (entry.quality.isNotEmpty)
|
|
Text(entry.quality),
|
|
if (entry.isSegmented)
|
|
Text(
|
|
tr(
|
|
context,
|
|
'Offline package · interrupted packaging resumes from verified segments after reopening. Export includes all media files.',
|
|
'بسته آفلاین · پس از باز کردن دوباره، از قطعههای تأییدشده ادامه مییابد. خروجی شامل همه فایلها است.',
|
|
),
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall,
|
|
),
|
|
const SizedBox(height: 10),
|
|
DownloadTransferDetails(entry: entry),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
DownloadControls(
|
|
key: ValueKey('controls:${entry.id}'),
|
|
entry: entry,
|
|
onPause: () => service.pause(entry),
|
|
onResume: () => service.resume(entry),
|
|
onRetry: () => service.retry(entry),
|
|
onCancel: () => service.cancel(entry),
|
|
),
|
|
if (entry.isComplete) ...[
|
|
FilledButton.tonalIcon(
|
|
icon: const Icon(Icons.play_arrow),
|
|
label: Text(
|
|
tr(
|
|
context,
|
|
'Play offline',
|
|
'پخش آفلاین',
|
|
),
|
|
),
|
|
onPressed: () =>
|
|
action(playOffline(entry)),
|
|
),
|
|
IconButton(
|
|
tooltip: tr(
|
|
context,
|
|
'Open file externally',
|
|
'باز کردن فایل بیرونی',
|
|
),
|
|
icon: const Icon(Icons.open_in_new),
|
|
onPressed: () =>
|
|
action(service.open(entry)),
|
|
),
|
|
IconButton(
|
|
tooltip: tr(
|
|
context,
|
|
'Reveal / export offline media',
|
|
'نمایش / خروجی رسانه آفلاین',
|
|
),
|
|
icon: const Icon(Icons.ios_share),
|
|
onPressed: () =>
|
|
action(service.reveal(entry)),
|
|
),
|
|
],
|
|
if (!entry.isSegmented)
|
|
ExternalDownloadMenu(
|
|
onSelected: (package) => action(
|
|
service.externalDownloader(
|
|
entry,
|
|
packageName: package,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: tr(
|
|
context,
|
|
'Delete file and record',
|
|
'حذف فایل و سابقه',
|
|
),
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: () async {
|
|
final confirmed =
|
|
await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) =>
|
|
AlertDialog(
|
|
title: Text(
|
|
tr(
|
|
context,
|
|
'Delete this download?',
|
|
'این دانلود حذف شود؟',
|
|
),
|
|
),
|
|
content: Text(entry.title),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () =>
|
|
Navigator.pop(
|
|
dialogContext,
|
|
false,
|
|
),
|
|
child: Text(
|
|
tr(
|
|
context,
|
|
'Cancel',
|
|
'انصراف',
|
|
),
|
|
),
|
|
),
|
|
FilledButton(
|
|
onPressed: () =>
|
|
Navigator.pop(
|
|
dialogContext,
|
|
true,
|
|
),
|
|
child: Text(
|
|
tr(
|
|
context,
|
|
'Delete',
|
|
'حذف',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true) {
|
|
await action(service.remove(entry));
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
Future<void> playOffline(DownloadEntry entry) async {
|
|
final path = await service.offlinePath(entry);
|
|
if (!mounted) return;
|
|
final title = entry.restoredTitle;
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => PlayerPage(
|
|
title: title,
|
|
source: MediaSource(
|
|
url: entry.url,
|
|
quality: entry.quality,
|
|
type: entry.sourceType,
|
|
),
|
|
localFile: path,
|
|
episode: entry.episodeId == null
|
|
? null
|
|
: EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|