[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+498
View File
@@ -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),
),
),
);
}
}