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 Function() onPause, onResume, onRetry, onCancel; @override State createState() => _DownloadControlsState(); } class _DownloadControlsState extends State { bool _busy = false; Future _run(Future 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 onSelected; @override Widget build(BuildContext context) => PopupMenuButton( 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 createState() => _DownloadsPageState(); } class _DownloadsPageState extends State { final service = DownloadService.instance; Future? startup; @override void didChangeDependencies() { super.didChangeDependencies(); service.bindSettings(AppScope.read(context)); startup ??= service.initialize(); } Future action(Future 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( 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( 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 playOffline(DownloadEntry entry) async { final path = await service.offlinePath(entry); if (!mounted) return; final title = entry.restoredTitle; await Navigator.of(context).push( MaterialPageRoute( 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), ), ), ); } }