[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,498 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user