[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,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../data/content_policy.dart';
|
||||
import 'local_media_server.dart';
|
||||
import 'segmented_download.dart';
|
||||
import 'subtitle_webvtt.dart';
|
||||
|
||||
/// Explicit resource ownership, also injectable into the native-session tests.
|
||||
class CastMediaLease {
|
||||
CastMediaLease({required Map<String, dynamic> payload, this._onClose})
|
||||
: _payload = Map<String, dynamic>.unmodifiable(payload);
|
||||
final Map<String, dynamic> _payload;
|
||||
final Future<void> Function()? _onClose;
|
||||
Future<void>? _closing;
|
||||
Map<String, dynamic> get payload => _payload;
|
||||
Future<void> close() => _closing ??= Future<void>.sync(() async {
|
||||
await _onClose?.call();
|
||||
});
|
||||
}
|
||||
|
||||
/// No filesystem or network side effects until an explicit Cast load.
|
||||
class CastMediaShare {
|
||||
static Future<CastMediaLease> prepare({
|
||||
required Map<String, dynamic> payload,
|
||||
String? localFile,
|
||||
String? subtitleText,
|
||||
InternetAddress? bindAddress,
|
||||
String? advertisedHost,
|
||||
}) async {
|
||||
final result = Map<String, dynamic>.from(payload)..remove('subtitleUrl');
|
||||
File? selected;
|
||||
final extraFiles = <String, File>{};
|
||||
if (localFile != null) {
|
||||
if (!File(localFile).isAbsolute) {
|
||||
throw ArgumentError('Select an absolute media file.');
|
||||
}
|
||||
selected = File(await File(localFile).resolveSymbolicLinks());
|
||||
final stat = await selected.stat();
|
||||
if (stat.type != FileSystemEntityType.file || stat.size <= 0) {
|
||||
throw ArgumentError('Select an existing nonempty media file.');
|
||||
}
|
||||
if (RegExp(
|
||||
r'\.(m3u8|mpd)$',
|
||||
caseSensitive: false,
|
||||
).hasMatch(selected.path)) {
|
||||
final directory = selected.parent;
|
||||
final index = File('${directory.path}/package.json');
|
||||
if (!await index.exists() || await index.length() > 16 * 1024 * 1024) {
|
||||
throw UnsupportedError('Select a complete indexed offline package.');
|
||||
}
|
||||
final id = directory.uri.pathSegments
|
||||
.where((part) => part.isNotEmpty)
|
||||
.last;
|
||||
final downloads = SegmentedDownload(root: directory.parent);
|
||||
final package = await downloads.load(id);
|
||||
if (await File(package.path).resolveSymbolicLinks() != selected.path ||
|
||||
!await downloads.validate(id)) {
|
||||
throw StateError('Offline package is incomplete or damaged.');
|
||||
}
|
||||
for (final name in {
|
||||
...package.manifests.keys,
|
||||
...package.resources.map((resource) => resource.filename),
|
||||
}) {
|
||||
final file = File('${directory.path}/$name');
|
||||
final canonical = File(await file.resolveSymbolicLinks());
|
||||
if (canonical.parent.path != directory.path) {
|
||||
throw StateError('Offline resource leaves the selected package.');
|
||||
}
|
||||
// The selected root is already registered under media.<extension>.
|
||||
if (name != 'media.${selected.path.split('.').last.toLowerCase()}') {
|
||||
extraFiles[name] = file;
|
||||
}
|
||||
}
|
||||
} else if (!RegExp(
|
||||
r'\.(mp4|m4v|mkv|webm|mov|avi|mp3|aac)$',
|
||||
caseSensitive: false,
|
||||
).hasMatch(selected.path)) {
|
||||
throw UnsupportedError(
|
||||
'This local media format requires an indexed offline package.',
|
||||
);
|
||||
}
|
||||
result['contentType'] = _mime(selected.path);
|
||||
} else {
|
||||
final url = result['url'];
|
||||
final uri = url is String ? Uri.tryParse(url) : null;
|
||||
if (uri == null ||
|
||||
!{'http', 'https'}.contains(uri.scheme) ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
!ContentPolicy.allowsUrl(url as String)) {
|
||||
throw ArgumentError('A permitted HTTP(S) media URL is required.');
|
||||
}
|
||||
}
|
||||
Directory? disposable;
|
||||
LocalMediaServer? server;
|
||||
try {
|
||||
File? subtitles;
|
||||
if (subtitleText != null) {
|
||||
if (utf8.encode(subtitleText).length > 8 * 1024 * 1024) {
|
||||
throw const FormatException('Subtitle exceeds 8 MB.');
|
||||
}
|
||||
final vtt = SubtitleWebVtt.convert(subtitleText);
|
||||
disposable = await Directory.systemTemp.createTemp('reelbox-cast-');
|
||||
subtitles = await File('${disposable.path}/selected.vtt')
|
||||
.writeAsString(vtt, encoding: utf8, flush: true);
|
||||
}
|
||||
if (selected == null && subtitles == null) {
|
||||
return CastMediaLease(payload: result);
|
||||
}
|
||||
final sharing = await LocalMediaServer.start(
|
||||
file: selected ?? subtitles!,
|
||||
subtitle: selected == null ? null : subtitles,
|
||||
bindAddress: bindAddress,
|
||||
advertisedHost: advertisedHost,
|
||||
extraFiles: extraFiles,
|
||||
);
|
||||
server = sharing;
|
||||
if (selected != null) result['url'] = sharing.mediaUri.toString();
|
||||
if (subtitles != null) {
|
||||
result['subtitleUrl'] =
|
||||
(selected == null ? sharing.mediaUri : sharing.subtitleUri!)
|
||||
.toString();
|
||||
}
|
||||
final owned = disposable;
|
||||
return CastMediaLease(
|
||||
payload: result,
|
||||
onClose: () async {
|
||||
try {
|
||||
await sharing.close();
|
||||
} finally {
|
||||
if (owned != null && await owned.exists()) {
|
||||
await owned.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
try {
|
||||
await server?.close();
|
||||
} finally {
|
||||
if (disposable != null && await disposable.exists()) {
|
||||
await disposable.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static String _mime(String path) =>
|
||||
switch (path.split('.').last.toLowerCase()) {
|
||||
'webm' => 'video/webm',
|
||||
'mkv' => 'video/x-matroska',
|
||||
'mov' => 'video/quicktime',
|
||||
'avi' => 'video/x-msvideo',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'aac' => 'audio/aac',
|
||||
'mpd' => 'application/dash+xml',
|
||||
'm3u8' => 'application/vnd.apple.mpegurl',
|
||||
_ => 'video/mp4',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'download_service.dart';
|
||||
import 'downloads_page.dart';
|
||||
|
||||
typedef EnqueueDownload = Future<void> Function({
|
||||
required TitleRecord title,
|
||||
required MediaSource source,
|
||||
EpisodeRecord? episode,
|
||||
});
|
||||
|
||||
/// The primary action always queues inside Reelbox, never an external app.
|
||||
class DownloadButton extends StatefulWidget {
|
||||
const DownloadButton({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.source,
|
||||
this.episode,
|
||||
this.enqueue,
|
||||
this.openDownloads,
|
||||
});
|
||||
|
||||
final TitleRecord title;
|
||||
final MediaSource source;
|
||||
final EpisodeRecord? episode;
|
||||
final EnqueueDownload? enqueue;
|
||||
final void Function(BuildContext context)? openDownloads;
|
||||
|
||||
@override
|
||||
State<DownloadButton> createState() => _DownloadButtonState();
|
||||
}
|
||||
|
||||
class _DownloadButtonState extends State<DownloadButton> {
|
||||
bool _starting = false;
|
||||
|
||||
Future<void> _download() async {
|
||||
if (_starting) return;
|
||||
setState(() => _starting = true);
|
||||
try {
|
||||
final host = Uri.tryParse(widget.source.url)?.host.toLowerCase() ?? '';
|
||||
if (RegExp(
|
||||
'youtube|embed',
|
||||
caseSensitive: false,
|
||||
).hasMatch(widget.source.type) ||
|
||||
host == 'youtu.be' ||
|
||||
host == 'youtube.com' ||
|
||||
host.endsWith('.youtube.com')) {
|
||||
throw UnsupportedError(
|
||||
tr(
|
||||
context,
|
||||
'Embedded sources cannot be downloaded. Choose a direct media source.',
|
||||
'منابع جاسازیشده قابل دانلود نیستند. یک منبع رسانه مستقیم انتخاب کنید.',
|
||||
),
|
||||
);
|
||||
}
|
||||
await (widget.enqueue ?? DownloadService.instance.enqueue)(
|
||||
title: widget.title,
|
||||
source: widget.source,
|
||||
episode: widget.episode,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (widget.openDownloads case final open?) {
|
||||
open(context);
|
||||
} else {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => const DownloadsPage()));
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${tr(context, 'Could not start download', 'شروع دانلود ممکن نشد')}: ${downloadErrorMessage(error)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _starting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FilledButton.icon(
|
||||
onPressed: widget.source.url.isEmpty || _starting ? null : _download,
|
||||
icon: _starting
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_outlined),
|
||||
label: Text(tr(context, 'Download in Reelbox', 'دانلود در ریلباکس')),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../data/content_policy.dart';
|
||||
import 'media_platform.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'media_helpers.dart';
|
||||
import 'media_files_stub.dart'
|
||||
if (dart.library.io) 'media_files_io.dart'
|
||||
as files;
|
||||
|
||||
/// A snapshot of the persistent native task, not an optimistic success flag.
|
||||
class DownloadEntry {
|
||||
final Task task;
|
||||
final String id,
|
||||
title,
|
||||
quality,
|
||||
filename,
|
||||
path,
|
||||
url,
|
||||
status,
|
||||
error,
|
||||
episodeTitle,
|
||||
posterImage;
|
||||
final int titleId;
|
||||
final String titleKind;
|
||||
final bool? downloadNotifications;
|
||||
final int? episodeId;
|
||||
final double progress, speed;
|
||||
final int receivedBytes, totalBytes;
|
||||
final TaskStatus taskStatus;
|
||||
|
||||
DownloadEntry._({
|
||||
required this.task,
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.quality,
|
||||
required this.filename,
|
||||
required this.path,
|
||||
required this.url,
|
||||
required this.status,
|
||||
required this.error,
|
||||
required this.episodeTitle,
|
||||
required this.posterImage,
|
||||
required this.titleId,
|
||||
required this.titleKind,
|
||||
required this.downloadNotifications,
|
||||
required this.episodeId,
|
||||
required this.progress,
|
||||
required this.speed,
|
||||
required this.receivedBytes,
|
||||
required this.totalBytes,
|
||||
required this.taskStatus,
|
||||
});
|
||||
|
||||
factory DownloadEntry.fromTask(
|
||||
Task task, {
|
||||
TaskStatus taskStatus = TaskStatus.enqueued,
|
||||
double progress = 0,
|
||||
int totalBytes = 0,
|
||||
int? receivedBytes,
|
||||
double speed = 0,
|
||||
String path = '',
|
||||
String error = '',
|
||||
}) {
|
||||
Map<String, dynamic> meta = {};
|
||||
try {
|
||||
final decoded = jsonDecode(task.metaData);
|
||||
if (decoded is Map) meta = Map<String, dynamic>.from(decoded);
|
||||
} catch (_) {
|
||||
/* Tasks from older installs can lack metadata. */
|
||||
}
|
||||
final fraction = taskStatus == TaskStatus.complete
|
||||
? 1.0
|
||||
: progress.isFinite
|
||||
? progress.clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
final size = totalBytes > 0 ? totalBytes : 0;
|
||||
return DownloadEntry._(
|
||||
task: task,
|
||||
id: task.taskId,
|
||||
title: meta['title'] as String? ?? task.displayName,
|
||||
quality: meta['quality'] as String? ?? '',
|
||||
filename: task.filename,
|
||||
path: path,
|
||||
url: task.url,
|
||||
status: taskStatus.name,
|
||||
error: error,
|
||||
episodeTitle: meta['episodeTitle'] as String? ?? '',
|
||||
posterImage: meta['posterImage'] as String? ?? '',
|
||||
titleId: meta['titleId'] as int? ?? 0,
|
||||
titleKind: (meta['titleKind'] as String?)?.isNotEmpty == true
|
||||
? meta['titleKind'] as String
|
||||
: meta['episodeId'] == null
|
||||
? 'movie'
|
||||
: 'serie',
|
||||
downloadNotifications:
|
||||
meta['downloadNotifications'] as bool? ??
|
||||
(task.notificationConfig != null ? true : null),
|
||||
episodeId: meta['episodeId'] as int?,
|
||||
progress: fraction,
|
||||
speed: speed.isFinite && speed > 0 ? speed : 0,
|
||||
receivedBytes: receivedBytes ?? (fraction * size).round(),
|
||||
totalBytes: size,
|
||||
taskStatus: taskStatus,
|
||||
);
|
||||
}
|
||||
|
||||
TitleRecord get restoredTitle => TitleRecord(
|
||||
id: titleId,
|
||||
title: title,
|
||||
image: posterImage,
|
||||
type: titleKind,
|
||||
);
|
||||
|
||||
String get sourceType {
|
||||
try {
|
||||
return (jsonDecode(task.metaData) as Map)['sourceType'] as String? ?? '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
bool get isSegmented => task.group == DownloadService.packageGroup;
|
||||
bool get visible =>
|
||||
ContentPolicy.allowsTitleJson(restoredTitle.toJson()) &&
|
||||
ContentPolicy.allowsText(episodeTitle) &&
|
||||
ContentPolicy.allowsUrl(url);
|
||||
bool get isComplete => taskStatus == TaskStatus.complete;
|
||||
bool get canPause => taskStatus == TaskStatus.running;
|
||||
bool get canResume => taskStatus == TaskStatus.paused;
|
||||
bool get isActive => {
|
||||
TaskStatus.running,
|
||||
TaskStatus.enqueued,
|
||||
TaskStatus.waitingToRetry,
|
||||
}.contains(taskStatus);
|
||||
}
|
||||
|
||||
/// Resolve a fresh durable snapshot, awaiting both storage and file paths.
|
||||
Future<List<DownloadEntry>> readDownloadEntries(
|
||||
Future<List<TaskRecord>> Function() load,
|
||||
) async {
|
||||
final records = await load();
|
||||
return Future.wait(
|
||||
records.map(
|
||||
(record) async => DownloadEntry.fromTask(
|
||||
record.task,
|
||||
taskStatus: record.status,
|
||||
progress: record.progress,
|
||||
totalBytes: record.expectedFileSize,
|
||||
path: await record.task.filePath(),
|
||||
error: record.exception?.description ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskNotificationConfig? downloadNotificationConfig(bool enabled) => enabled
|
||||
? TaskNotificationConfig(
|
||||
running: const TaskNotification(
|
||||
'Reelbox • downloading',
|
||||
'{displayName}\n{progress} • {networkSpeed}',
|
||||
),
|
||||
complete: const TaskNotification(
|
||||
'Reelbox • downloaded',
|
||||
'{displayName}',
|
||||
),
|
||||
error: const TaskNotification(
|
||||
'Reelbox • download failed',
|
||||
'{displayName}',
|
||||
),
|
||||
paused: const TaskNotification('Reelbox • paused', '{displayName}'),
|
||||
progressBar: true,
|
||||
tapOpensFile: true,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Shared by enqueue and restart recovery tests; metadata is the durable policy.
|
||||
String downloadMetadata({
|
||||
required TitleRecord title,
|
||||
required MediaSource source,
|
||||
required bool notifications,
|
||||
EpisodeRecord? episode,
|
||||
}) => jsonEncode({
|
||||
'title': title.title,
|
||||
'titleId': title.id,
|
||||
'titleKind': title.type,
|
||||
'downloadNotifications': notifications,
|
||||
'quality': source.quality,
|
||||
'sourceType': source.type,
|
||||
'episodeId': episode?.id,
|
||||
'episodeTitle': episode?.title ?? '',
|
||||
'posterImage': title.image,
|
||||
});
|
||||
|
||||
Future<bool> resumeDownloadTask(
|
||||
DownloadEntry entry, {
|
||||
required void Function(Task task, TaskNotificationConfig config) configure,
|
||||
required Future<bool> Function(DownloadTask task) resume,
|
||||
}) async {
|
||||
if (entry.task is! DownloadTask) return false;
|
||||
// Task.notificationConfig is deliberately omitted by plugin serialization.
|
||||
// Unknown legacy policy stays silent rather than adopting today's setting.
|
||||
final config = downloadNotificationConfig(
|
||||
entry.downloadNotifications ?? false,
|
||||
);
|
||||
// The pinned plugin rejects an all-null notification configuration. Off-policy
|
||||
// tasks have no templates registered (and this service has no group/default).
|
||||
if (config != null) configure(entry.task, config);
|
||||
return resume(entry.task as DownloadTask);
|
||||
}
|
||||
|
||||
DownloadTask createDirectDownloadTask({
|
||||
required String id,
|
||||
required TitleRecord title,
|
||||
required MediaSource source,
|
||||
required AppSettings preferences,
|
||||
EpisodeRecord? episode,
|
||||
bool supportsParallel = false,
|
||||
}) {
|
||||
final task = DownloadTask(
|
||||
taskId: id,
|
||||
url: source.url,
|
||||
filename: downloadFilename(
|
||||
title: episode == null ? title.title : '${title.title} ${episode.title}',
|
||||
identity: id,
|
||||
url: source.url,
|
||||
),
|
||||
baseDirectory: BaseDirectory.applicationSupport,
|
||||
directory: 'offline',
|
||||
group: DownloadService.group,
|
||||
notificationConfig: downloadNotificationConfig(
|
||||
preferences.downloadNotifications,
|
||||
),
|
||||
updates: Updates.statusAndProgress,
|
||||
requiresWiFi: preferences.wifiOnlyDownloads,
|
||||
allowPause: true,
|
||||
retries: 2,
|
||||
displayName: episode == null
|
||||
? title.title
|
||||
: '${title.title} • ${episode.title}',
|
||||
metaData: downloadMetadata(
|
||||
title: title,
|
||||
source: source,
|
||||
episode: episode,
|
||||
notifications: preferences.downloadNotifications,
|
||||
),
|
||||
);
|
||||
|
||||
// A normal GET is the compatibility path. Multipart workers require HEAD,
|
||||
// a known length and advertised byte ranges; settings alone prove none of it.
|
||||
if (preferences.parallelDownloads <= 1 || !supportsParallel) return task;
|
||||
// 9.6.5 exposes real HTTP range chunk tasks, not concurrent whole-file copies.
|
||||
return ParallelDownloadTask(
|
||||
taskId: task.taskId,
|
||||
url: task.url,
|
||||
filename: task.filename,
|
||||
chunks: preferences.parallelDownloads.clamp(1, 8),
|
||||
baseDirectory: task.baseDirectory,
|
||||
directory: task.directory,
|
||||
group: task.group,
|
||||
notificationConfig: task.notificationConfig,
|
||||
updates: task.updates,
|
||||
requiresWiFi: task.requiresWiFi,
|
||||
allowPause: true,
|
||||
retries: task.retries,
|
||||
displayName: task.displayName,
|
||||
metaData: task.metaData,
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuild a failed task without requiring a broken multipart server feature.
|
||||
/// Identity, original network/notification policy and source headers survive.
|
||||
DownloadTask createRetryDownloadTask(
|
||||
DownloadEntry entry, {
|
||||
required String id,
|
||||
bool supportsParallel = false,
|
||||
}) {
|
||||
if (entry.task is! DownloadTask) {
|
||||
throw UnsupportedError('This task is not downloadable.');
|
||||
}
|
||||
final original = entry.task is ParallelDownloadTask && !supportsParallel
|
||||
? DownloadTask.fromJson({
|
||||
...entry.task.toJson(),
|
||||
'taskType': 'DownloadTask',
|
||||
})
|
||||
: entry.task as DownloadTask;
|
||||
return original.copyWith(
|
||||
taskId: id,
|
||||
retriesRemaining: original.retries,
|
||||
filename: downloadFilename(
|
||||
title: entry.title,
|
||||
identity: id,
|
||||
url: entry.url,
|
||||
),
|
||||
notificationConfig: downloadNotificationConfig(
|
||||
entry.downloadNotifications ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> retireExcludedDownload(
|
||||
TaskRecord record, {
|
||||
required Future<bool> Function(String) cancel,
|
||||
}) async {
|
||||
final entry = DownloadEntry.fromTask(record.task, taskStatus: record.status);
|
||||
if (!entry.visible && (entry.isActive || entry.canResume)) {
|
||||
// Cancel suppresses stale OS notifications; no files or records are deleted.
|
||||
if (!await cancel(entry.id)) {
|
||||
throw StateError('Could not stop an excluded legacy download.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> replaceFailedDownload(
|
||||
Task old,
|
||||
DownloadTask replacement, {
|
||||
required Future<bool> Function(DownloadTask) enqueue,
|
||||
required Future<void> Function(String) deleteRecord,
|
||||
}) async {
|
||||
if (!await enqueue(replacement)) {
|
||||
throw StateError(
|
||||
'Android rejected the replacement download; the old record was retained.',
|
||||
);
|
||||
}
|
||||
await deleteRecord(old.taskId);
|
||||
}
|
||||
|
||||
/// Native WorkManager/UIDT jobs with the plugin's durable task database.
|
||||
/// No database auto-cleanup: completed downloads remain in the offline library.
|
||||
class DownloadService extends ChangeNotifier {
|
||||
DownloadService._();
|
||||
static final DownloadService instance = DownloadService._();
|
||||
static const group = 'reelbox.video';
|
||||
static const packageGroup = 'reelbox.offline.packages';
|
||||
final Map<String, DownloadEntry> _entries = {};
|
||||
final Set<String> _removing = {};
|
||||
Future<void>? _initialization;
|
||||
StreamSubscription<TaskRecord>? _databaseSubscription;
|
||||
AppState? _state;
|
||||
bool _ready = false;
|
||||
bool get supported =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
String? initializationError;
|
||||
List<DownloadEntry> get items {
|
||||
// Hide excluded legacy records without deleting the user's actual files.
|
||||
final result = _entries.values.where((e) => e.visible).toList()
|
||||
..sort((a, b) => b.task.creationTime.compareTo(a.task.creationTime));
|
||||
return List.unmodifiable(result);
|
||||
}
|
||||
|
||||
FileDownloader get _downloader => FileDownloader();
|
||||
|
||||
/// Optional live settings binding; otherwise read durable AppState settings
|
||||
/// before each enqueue so the public enqueue signature needs no context.
|
||||
void bindSettings(AppState state) {
|
||||
if (identical(_state, state)) return;
|
||||
_state?.removeListener(_settingsChanged);
|
||||
_state = state;
|
||||
state.addListener(_settingsChanged);
|
||||
_settingsChanged();
|
||||
}
|
||||
|
||||
void _settingsChanged() {
|
||||
if (_state != null) {
|
||||
configureNotifications(_state!.prefs.downloadNotifications);
|
||||
}
|
||||
if (_ready) {
|
||||
unawaited(
|
||||
_applyNetworkPolicy().catchError((Object e) {
|
||||
initializationError = 'Could not apply download network settings.';
|
||||
notifyListeners();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppSettings> _preferences() async {
|
||||
if (_state != null) return _state!.prefs;
|
||||
final storage = await SharedPreferences.getInstance();
|
||||
final raw = storage.getString(AppState.storageKey);
|
||||
if (raw == null) return AppSettings();
|
||||
final library = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return AppSettings.fromJson(
|
||||
Map<String, dynamic>.from(library['settings'] as Map),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _applyNetworkPolicy() async {
|
||||
final prefs = await _preferences();
|
||||
final ok = await _downloader.requireWiFi(
|
||||
prefs.wifiOnlyDownloads
|
||||
? RequireWiFi.forAllTasks
|
||||
: RequireWiFi.forNoTasks,
|
||||
);
|
||||
if (!ok) {
|
||||
throw StateError(
|
||||
'The OS could not update the download network constraint.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize() {
|
||||
if (!supported) {
|
||||
initializationError =
|
||||
'Background video downloads are available on Android only.';
|
||||
return Future.value();
|
||||
}
|
||||
return _initialization ??= _initialize().catchError((
|
||||
Object error,
|
||||
StackTrace stack,
|
||||
) {
|
||||
_initialization = null;
|
||||
initializationError =
|
||||
'Native download initialization failed. Retry on Android.';
|
||||
notifyListeners();
|
||||
Error.throwWithStackTrace(error, stack);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
// Group callbacks avoid stealing the plugin's single-subscription stream.
|
||||
_downloader.registerCallbacks(
|
||||
group: group,
|
||||
taskStatusCallback: _status,
|
||||
taskProgressCallback: _progress,
|
||||
);
|
||||
_databaseSubscription ??= _downloader.database.updates.listen((record) {
|
||||
if (record.task.group == group) unawaited(_acceptRecord(record));
|
||||
});
|
||||
for (final record in await _downloader.database.allRecords(group: group)) {
|
||||
await retireExcludedDownload(
|
||||
record,
|
||||
cancel: _downloader.cancelTaskWithId,
|
||||
);
|
||||
}
|
||||
await _downloader.start(autoCleanDatabase: false);
|
||||
await _applyNetworkPolicy();
|
||||
for (final record in await _downloader.database.allRecords(group: group)) {
|
||||
await _acceptRecord(record);
|
||||
}
|
||||
for (final package in await files.recoverPackages()) {
|
||||
_acceptPackage(package);
|
||||
}
|
||||
configureNotifications((await _preferences()).downloadNotifications);
|
||||
_ready = true;
|
||||
initializationError = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _notificationsEnabled = true;
|
||||
TaskNotificationConfig? get notificationConfigForNewTask =>
|
||||
downloadNotificationConfig(_notificationsEnabled);
|
||||
|
||||
/// Task-scoped configuration prevents stale group templates from leaking into
|
||||
/// new downloads after the preference is disabled. Active OS jobs keep their
|
||||
/// original notification policy until they finish.
|
||||
void configureNotifications(bool enabled) {
|
||||
_notificationsEnabled = enabled;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (!supported) {
|
||||
throw UnsupportedError('Download refresh requires Android.');
|
||||
}
|
||||
await initialize();
|
||||
final before = Map<String, DownloadEntry>.of(_entries);
|
||||
final snapshot = await readDownloadEntries(
|
||||
() => _downloader.database.allRecords(group: group),
|
||||
);
|
||||
final ids = snapshot.map((entry) => entry.id).toSet();
|
||||
for (final entry in snapshot) {
|
||||
// A native callback that arrived during I/O is newer than this snapshot.
|
||||
if (_removing.contains(entry.id) ||
|
||||
!identical(_entries[entry.id], before[entry.id])) {
|
||||
continue;
|
||||
}
|
||||
_entries[entry.id] = entry;
|
||||
}
|
||||
_entries.removeWhere(
|
||||
(id, entry) =>
|
||||
!entry.isSegmented &&
|
||||
!ids.contains(id) &&
|
||||
identical(entry, before[id]) &&
|
||||
!entry.isActive,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _acceptRecord(TaskRecord record) async {
|
||||
if (_removing.contains(record.taskId)) return;
|
||||
await retireExcludedDownload(record, cancel: _downloader.cancelTaskWithId);
|
||||
final previous = _entries[record.taskId];
|
||||
final path = await record.task.filePath();
|
||||
if (_removing.contains(record.taskId)) return;
|
||||
// A progress callback may have overtaken asynchronous path resolution.
|
||||
final latest = _entries[record.taskId];
|
||||
if (latest != null && !identical(latest, previous)) return;
|
||||
_entries[record.taskId] = DownloadEntry.fromTask(
|
||||
record.task,
|
||||
taskStatus: record.status,
|
||||
progress: record.progress,
|
||||
totalBytes: record.expectedFileSize,
|
||||
path: path,
|
||||
speed: previous?.speed ?? 0,
|
||||
error: record.exception?.description ?? '',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _status(TaskStatusUpdate update) {
|
||||
if (_removing.contains(update.task.taskId)) return;
|
||||
final old = _entries[update.task.taskId];
|
||||
_entries[update.task.taskId] = DownloadEntry.fromTask(
|
||||
update.task,
|
||||
taskStatus: update.status,
|
||||
progress: old?.progress ?? 0,
|
||||
totalBytes: old?.totalBytes ?? 0,
|
||||
path: old?.path ?? '',
|
||||
error: update.exception?.description ?? '',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _progress(TaskProgressUpdate update) {
|
||||
if (_removing.contains(update.task.taskId)) return;
|
||||
final old = _entries[update.task.taskId];
|
||||
_entries[update.task.taskId] = DownloadEntry.fromTask(
|
||||
update.task,
|
||||
taskStatus: old?.taskStatus ?? TaskStatus.running,
|
||||
progress: update.progress >= 0 ? update.progress : old?.progress ?? 0,
|
||||
totalBytes: update.expectedFileSize > 0
|
||||
? update.expectedFileSize
|
||||
: old?.totalBytes ?? 0,
|
||||
path: old?.path ?? '',
|
||||
speed: update.hasNetworkSpeed ? update.networkSpeed * 1000000 : 0,
|
||||
error: old?.error ?? '',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> enqueue({
|
||||
required TitleRecord title,
|
||||
required MediaSource source,
|
||||
EpisodeRecord? episode,
|
||||
}) async {
|
||||
if (!supported) {
|
||||
throw UnsupportedError('Background downloads require the Android app.');
|
||||
}
|
||||
if (title.id <= 0 ||
|
||||
title.title.trim().isEmpty ||
|
||||
!ContentPolicy.allowsTitleJson(title.toJson()) ||
|
||||
!ContentPolicy.allowsText(episode?.title ?? '') ||
|
||||
!ContentPolicy.allowsUrl(source.url)) {
|
||||
throw UnsupportedError(
|
||||
'This title or destination is excluded from Reelbox.',
|
||||
);
|
||||
}
|
||||
if (!isRemoteMedia(source.url) ||
|
||||
RegExp(
|
||||
r'drm|widevine|playready|fairplay|protected',
|
||||
caseSensitive: false,
|
||||
).hasMatch(source.type)) {
|
||||
throw UnsupportedError(
|
||||
'Only authorized, non-DRM HTTP(S) media can be downloaded.',
|
||||
);
|
||||
}
|
||||
await initialize();
|
||||
await _applyNetworkPolicy();
|
||||
if (_entries.values.any(
|
||||
(entry) =>
|
||||
entry.url == source.url &&
|
||||
(entry.isActive || entry.canResume || entry.isComplete),
|
||||
)) {
|
||||
throw StateError('This source is already in Downloads.');
|
||||
}
|
||||
final prefs = await _preferences();
|
||||
configureNotifications(prefs.downloadNotifications);
|
||||
if (prefs.downloadNotifications &&
|
||||
await _downloader.permissions.status(PermissionType.notifications) !=
|
||||
PermissionStatus.granted) {
|
||||
await _downloader.permissions.request(PermissionType.notifications);
|
||||
}
|
||||
final id =
|
||||
'${title.id}_${episode?.id ?? 0}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
final task = createDirectDownloadTask(
|
||||
id: id,
|
||||
title: title,
|
||||
source: source,
|
||||
preferences: prefs,
|
||||
episode: episode,
|
||||
supportsParallel:
|
||||
!_segmentedSource(source) &&
|
||||
prefs.parallelDownloads > 1 &&
|
||||
await files.supportsParallelDownload(source.url),
|
||||
);
|
||||
if (_segmentedSource(source)) {
|
||||
if (prefs.wifiOnlyDownloads && !_downloader.isWiFi) {
|
||||
throw StateError(
|
||||
'Connect to Wi-Fi before preparing an offline package.',
|
||||
);
|
||||
}
|
||||
final snapshot = await files.preparePackage(
|
||||
id: id,
|
||||
url: source.url,
|
||||
type: source.type,
|
||||
parallel: prefs.parallelDownloads,
|
||||
metadata: {
|
||||
'task': task.copyWith(group: packageGroup).toJson(),
|
||||
'notifications': prefs.downloadNotifications,
|
||||
},
|
||||
);
|
||||
_acceptPackage(snapshot);
|
||||
_startPackage(id);
|
||||
return;
|
||||
}
|
||||
final enqueued = await _downloader.enqueue(task);
|
||||
if (!enqueued) {
|
||||
throw StateError('Android rejected the background download job.');
|
||||
}
|
||||
// Read back the exact persistent task instead of calling queued success complete.
|
||||
final record = await _downloader.database.recordForId(task.taskId);
|
||||
if (record != null) {
|
||||
await _acceptRecord(record);
|
||||
} else {
|
||||
_entries.putIfAbsent(id, () => DownloadEntry.fromTask(task));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
static bool _segmentedSource(MediaSource source) =>
|
||||
isSegmentedMediaSource(source);
|
||||
final _packageRuns = <String>{};
|
||||
void _acceptPackage(Map<String, dynamic> snapshot) {
|
||||
final metadata = Map<String, dynamic>.from(snapshot['metadata'] as Map);
|
||||
final original = Task.createFromJson(
|
||||
Map<String, dynamic>.from(metadata['task'] as Map),
|
||||
);
|
||||
final task = original.copyWith(filename: snapshot['manifest'] as String);
|
||||
if (_removing.contains(task.taskId)) return;
|
||||
final status = TaskStatus.values.firstWhere(
|
||||
(s) => s.name == snapshot['status'],
|
||||
orElse: () => TaskStatus.failed,
|
||||
);
|
||||
final bytes = (snapshot['receivedBytes'] as num?)?.toInt() ?? 0;
|
||||
_entries[task.taskId] = DownloadEntry.fromTask(
|
||||
task,
|
||||
taskStatus: status,
|
||||
progress: (snapshot['progress'] as num?)?.toDouble() ?? 0,
|
||||
receivedBytes: bytes,
|
||||
totalBytes: status == TaskStatus.complete ? bytes : 0,
|
||||
path: snapshot['path'] as String,
|
||||
error: snapshot['error'] as String? ?? '',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startPackage(String id) {
|
||||
if (!_packageRuns.add(id)) {
|
||||
throw StateError('This package is already active.');
|
||||
}
|
||||
unawaited(
|
||||
files
|
||||
.runPackage(id, _acceptPackage)
|
||||
.catchError((Object e) {
|
||||
final previous = _entries[id];
|
||||
if (previous != null && !_removing.contains(id)) {
|
||||
_entries[id] = DownloadEntry.fromTask(
|
||||
previous.task,
|
||||
taskStatus: TaskStatus.failed,
|
||||
path: previous.path,
|
||||
progress: previous.progress,
|
||||
receivedBytes: previous.receivedBytes,
|
||||
error: 'Offline package could not start: $e',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
})
|
||||
.whenComplete(() => _packageRuns.remove(id)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Retries preserve the original URL/type, identity and notification policy.
|
||||
/// The old failed RECORD is removed only after successful new native enqueue;
|
||||
/// legacy user files are not removed as a side effect of replacing a record.
|
||||
Future<void> retry(DownloadEntry entry) async {
|
||||
await initialize();
|
||||
if (!entry.visible || entry.isActive || entry.canResume) {
|
||||
throw StateError('This download cannot be retried now.');
|
||||
}
|
||||
if (entry.isSegmented) {
|
||||
_startPackage(entry.id);
|
||||
return;
|
||||
}
|
||||
if (entry.task is! DownloadTask) {
|
||||
throw UnsupportedError('This task is not downloadable.');
|
||||
}
|
||||
final source = MediaSource(
|
||||
url: entry.url,
|
||||
quality: entry.quality,
|
||||
type: entry.sourceType,
|
||||
);
|
||||
if (_segmentedSource(source)) {
|
||||
await enqueue(
|
||||
title: entry.restoredTitle,
|
||||
source: source,
|
||||
episode: entry.episodeId == null
|
||||
? null
|
||||
: EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle),
|
||||
);
|
||||
await _downloader.database.deleteRecordWithId(entry.id);
|
||||
} else {
|
||||
final id =
|
||||
'${entry.titleId}_${entry.episodeId ?? 0}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
// A failed multipart attempt must not loop on the same server feature,
|
||||
// even if HEAD incorrectly advertised ranges. Retry as one normal GET.
|
||||
final next = createRetryDownloadTask(entry, id: id);
|
||||
await replaceFailedDownload(
|
||||
entry.task,
|
||||
next,
|
||||
enqueue: _downloader.enqueue,
|
||||
deleteRecord: (id) async {
|
||||
await _downloader.database.deleteRecordWithId(id);
|
||||
if (await _downloader.database.recordForId(id) != null) {
|
||||
throw StateError('Could not replace the old download record.');
|
||||
}
|
||||
},
|
||||
);
|
||||
final record = await _downloader.database.recordForId(id);
|
||||
if (record != null) {
|
||||
await _acceptRecord(record);
|
||||
} else {
|
||||
_entries.putIfAbsent(id, () => DownloadEntry.fromTask(next));
|
||||
}
|
||||
}
|
||||
_entries.remove(entry.id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> reveal(DownloadEntry entry) async {
|
||||
final local = await offlinePath(entry);
|
||||
final path = entry.isSegmented
|
||||
? await files.exportPackage(entry.id)
|
||||
: local;
|
||||
if (await MediaPlatform.invoke<bool>('revealFile', {'path': path}) !=
|
||||
true) {
|
||||
throw StateError(
|
||||
'No application could reveal or export this offline media.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> externalDownloader(
|
||||
DownloadEntry entry, {
|
||||
String packageName = 'ask',
|
||||
}) async {
|
||||
if (!entry.visible || !isRemoteMedia(entry.url)) {
|
||||
throw UnsupportedError('This media cannot be handed off.');
|
||||
}
|
||||
if (_segmentedSource(MediaSource(url: entry.url, type: entry.sourceType))) {
|
||||
throw UnsupportedError(
|
||||
'Export a complete offline package instead; external downloaders may save only the manifest.',
|
||||
);
|
||||
}
|
||||
if (await MediaPlatform.invoke<bool>('openDownloader', {
|
||||
'url': entry.url,
|
||||
'filename': entry.filename,
|
||||
'packageName': packageName,
|
||||
}) !=
|
||||
true) {
|
||||
throw StateError('No external downloader could accept this file.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause(DownloadEntry entry) async {
|
||||
await initialize();
|
||||
if (entry.isSegmented) {
|
||||
await files.pausePackage(entry.id);
|
||||
_acceptPackage(await files.loadPackage(entry.id));
|
||||
return;
|
||||
}
|
||||
if (entry.task is! DownloadTask ||
|
||||
!await _downloader.pause(entry.task as DownloadTask)) {
|
||||
throw StateError(
|
||||
'Pause is unavailable. This server may not support byte ranges.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resume(DownloadEntry entry) async {
|
||||
await initialize();
|
||||
if (!entry.visible) {
|
||||
throw UnsupportedError('This legacy title is excluded from Reelbox.');
|
||||
}
|
||||
if (entry.isSegmented) {
|
||||
_startPackage(entry.id);
|
||||
return;
|
||||
}
|
||||
if (!await resumeDownloadTask(
|
||||
entry,
|
||||
configure: (task, config) => _downloader.configureNotificationForTask(
|
||||
task,
|
||||
running: config.running,
|
||||
complete: config.complete,
|
||||
error: config.error,
|
||||
paused: config.paused,
|
||||
progressBar: config.progressBar,
|
||||
tapOpensFile: config.tapOpensFile,
|
||||
),
|
||||
resume: _downloader.resume,
|
||||
)) {
|
||||
throw StateError(
|
||||
'Cannot resume this task; remove it and start a new download.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancel(DownloadEntry entry) async {
|
||||
await initialize();
|
||||
if (entry.isSegmented) {
|
||||
await files.pausePackage(entry.id, cancel: true);
|
||||
_acceptPackage(await files.loadPackage(entry.id));
|
||||
return;
|
||||
}
|
||||
if (!await _downloader.cancelTaskWithId(entry.id)) {
|
||||
throw StateError('The OS could not cancel this download.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> remove(DownloadEntry entry) async {
|
||||
await initialize();
|
||||
if (entry.isActive || entry.canResume) await cancel(entry);
|
||||
_removing.add(entry.id);
|
||||
try {
|
||||
if (entry.isSegmented) {
|
||||
await files.deletePackage(entry.id);
|
||||
_entries.remove(entry.id);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
final path = await entry.task.filePath();
|
||||
await files.delete(path);
|
||||
await _downloader.database.deleteRecordWithId(entry.id);
|
||||
if (await _downloader.database.recordForId(entry.id) != null) {
|
||||
throw StateError('Could not remove download record.');
|
||||
}
|
||||
_entries.remove(entry.id);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_removing.remove(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> offlinePath(DownloadEntry entry) async {
|
||||
if (!entry.visible) {
|
||||
throw UnsupportedError('This legacy title is excluded from Reelbox.');
|
||||
}
|
||||
if (!entry.isComplete) throw StateError('The download is not complete.');
|
||||
if (entry.isSegmented) {
|
||||
if (!await files.validatePackage(entry.id)) {
|
||||
throw StateError(
|
||||
'Offline package files are missing or damaged. Retry to repair.',
|
||||
);
|
||||
}
|
||||
return (await files.loadPackage(entry.id))['path'] as String;
|
||||
}
|
||||
final path = await entry.task.filePath();
|
||||
if (!await files.exists(path) ||
|
||||
entry.totalBytes > 0 && await files.size(path) != entry.totalBytes) {
|
||||
throw StateError(
|
||||
'The downloaded file is missing. Remove the entry and download again.',
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<void> open(DownloadEntry entry) async {
|
||||
final path = await offlinePath(entry);
|
||||
if (!await _downloader.openFile(filePath: path, mimeType: 'video/*')) {
|
||||
throw StateError(
|
||||
'No installed application could open this video. Use Play offline in Reelbox.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
/// Explicit, short-lived private-LAN media sharing. The random session URL is a
|
||||
/// bearer capability; never log/persist it. Only caller-registered files exist
|
||||
/// in this HTTP namespace. No filesystem path is derived from a request.
|
||||
class LocalMediaServer {
|
||||
LocalMediaServer._(
|
||||
this._server,
|
||||
this._host,
|
||||
this._token,
|
||||
this._files,
|
||||
this._mediaName,
|
||||
this._subtitleName,
|
||||
) : _port = _server.port {
|
||||
_server.listen((request) {
|
||||
final operation = _handle(request);
|
||||
_pending.add(operation);
|
||||
unawaited(operation.whenComplete(() => _pending.remove(operation)));
|
||||
}, onError: (Object _) {});
|
||||
}
|
||||
|
||||
final HttpServer _server;
|
||||
final String _host, _token, _mediaName;
|
||||
final String? _subtitleName;
|
||||
final int _port;
|
||||
final Map<String, _RegisteredMedia> _files;
|
||||
final Set<Future<void>> _pending = {};
|
||||
Future<void>? _closing;
|
||||
bool _closed = false;
|
||||
|
||||
static Future<LocalMediaServer> start({
|
||||
required File file,
|
||||
File? subtitle,
|
||||
InternetAddress? bindAddress,
|
||||
String? advertisedHost,
|
||||
Map<String, File> extraFiles = const {},
|
||||
}) async {
|
||||
final address = bindAddress ?? await _discoverAddress();
|
||||
final hostAddress = InternetAddress.tryParse(
|
||||
advertisedHost ?? address.address,
|
||||
);
|
||||
if (hostAddress == null ||
|
||||
(!hostAddress.isLoopback && !_privateV4(hostAddress))) {
|
||||
throw ArgumentError(
|
||||
'Choose a private LAN address for local media sharing.',
|
||||
);
|
||||
}
|
||||
if (!address.isLoopback &&
|
||||
!_privateV4(address) &&
|
||||
address.address != '0.0.0.0') {
|
||||
throw ArgumentError(
|
||||
'Local media sharing cannot bind a public interface.',
|
||||
);
|
||||
}
|
||||
final mediaName = 'media${_extension(file)}';
|
||||
final subtitleName = subtitle == null
|
||||
? null
|
||||
: 'subtitle${_extension(subtitle)}';
|
||||
final selected = <String, File>{mediaName: file};
|
||||
if (subtitleName != null && subtitle != null) {
|
||||
selected[subtitleName] = subtitle;
|
||||
}
|
||||
for (final entry in extraFiles.entries) {
|
||||
if (!_safeName(entry.key) || selected.containsKey(entry.key)) {
|
||||
throw ArgumentError('Invalid or reserved local resource name.');
|
||||
}
|
||||
selected[entry.key] = entry.value;
|
||||
}
|
||||
final registered = <String, _RegisteredMedia>{};
|
||||
for (final entry in selected.entries) {
|
||||
registered[entry.key] = await _RegisteredMedia.create(entry.value);
|
||||
}
|
||||
final random = Random.secure();
|
||||
final token = base64Url
|
||||
.encode(List.generate(32, (_) => random.nextInt(256)))
|
||||
.replaceAll('=', '');
|
||||
final server = await HttpServer.bind(address, 0, shared: false);
|
||||
server.autoCompress = false;
|
||||
return LocalMediaServer._(
|
||||
server,
|
||||
hostAddress.address,
|
||||
token,
|
||||
registered,
|
||||
mediaName,
|
||||
subtitleName,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _safeName(String name) =>
|
||||
name.isNotEmpty &&
|
||||
name
|
||||
.split('/')
|
||||
.every(
|
||||
(part) =>
|
||||
part.isNotEmpty &&
|
||||
!part.startsWith('.') &&
|
||||
RegExp(r'^[a-zA-Z0-9_.-]+$').hasMatch(part),
|
||||
);
|
||||
|
||||
Uri _uri(String name) => Uri(
|
||||
scheme: 'http',
|
||||
host: _host,
|
||||
port: _port,
|
||||
pathSegments: [_token, ...name.split('/')],
|
||||
);
|
||||
Uri get mediaUri => _uri(_mediaName);
|
||||
Uri? get subtitleUri => _subtitleName == null ? null : _uri(_subtitleName);
|
||||
|
||||
Future<void> close() => _closing ??= _close();
|
||||
Future<void> _close() async {
|
||||
_closed = true;
|
||||
await _server.close(force: true);
|
||||
await Future.wait(_pending.toList());
|
||||
}
|
||||
|
||||
Future<void> _handle(HttpRequest request) async {
|
||||
final response = request.response;
|
||||
try {
|
||||
final parts = request.uri.pathSegments;
|
||||
final name = parts.length > 1 ? parts.skip(1).join('/') : '';
|
||||
final resource =
|
||||
parts.isNotEmpty && parts.first == _token && _safeName(name)
|
||||
? _files[name]
|
||||
: null;
|
||||
if (_closed || resource == null || !await resource.unchanged()) {
|
||||
response.statusCode = HttpStatus.notFound;
|
||||
response.contentLength = 0;
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
response.headers.set('access-control-allow-origin', '*');
|
||||
response.headers.set(
|
||||
'access-control-allow-methods',
|
||||
'GET, HEAD, OPTIONS',
|
||||
);
|
||||
response.headers.set('access-control-allow-headers', 'Range, If-Range');
|
||||
response.headers.set(
|
||||
'access-control-expose-headers',
|
||||
'Content-Length, Content-Range, Accept-Ranges',
|
||||
);
|
||||
if (request.headers.value('access-control-request-private-network') ==
|
||||
'true') {
|
||||
response.headers.set('access-control-allow-private-network', 'true');
|
||||
}
|
||||
response.headers.set(HttpHeaders.cacheControlHeader, 'no-store');
|
||||
if (request.method == 'OPTIONS') {
|
||||
response.statusCode = HttpStatus.noContent;
|
||||
response.contentLength = 0;
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
if (request.method != 'GET' && request.method != 'HEAD') {
|
||||
response.statusCode = HttpStatus.methodNotAllowed;
|
||||
response.headers.set(HttpHeaders.allowHeader, 'GET, HEAD, OPTIONS');
|
||||
response.contentLength = 0;
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
final size = resource.stat.size;
|
||||
response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes');
|
||||
response.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
_contentType(resource.source),
|
||||
);
|
||||
response.headers.set(
|
||||
HttpHeaders.lastModifiedHeader,
|
||||
HttpDate.format(resource.stat.modified.toUtc()),
|
||||
);
|
||||
var start = 0, end = size - 1;
|
||||
final range = request.headers.value(HttpHeaders.rangeHeader);
|
||||
if (range != null) {
|
||||
final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim());
|
||||
var valid =
|
||||
match != null &&
|
||||
size > 0 &&
|
||||
(match.group(1)!.isNotEmpty || match.group(2)!.isNotEmpty);
|
||||
if (valid) {
|
||||
if (match.group(1)!.isEmpty) {
|
||||
final suffix = int.tryParse(match.group(2)!);
|
||||
valid = suffix != null && suffix > 0;
|
||||
if (valid) start = max(0, size - suffix);
|
||||
} else {
|
||||
final first = int.tryParse(match.group(1)!);
|
||||
final last = match.group(2)!.isEmpty
|
||||
? size - 1
|
||||
: int.tryParse(match.group(2)!);
|
||||
valid =
|
||||
first != null && last != null && first < size && last >= first;
|
||||
if (valid) {
|
||||
start = first;
|
||||
end = min(last, size - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!valid) {
|
||||
response.statusCode = HttpStatus.requestedRangeNotSatisfiable;
|
||||
response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$size');
|
||||
response.contentLength = 0;
|
||||
await response.close();
|
||||
return;
|
||||
}
|
||||
response.statusCode = HttpStatus.partialContent;
|
||||
response.headers.set(
|
||||
HttpHeaders.contentRangeHeader,
|
||||
'bytes $start-$end/$size',
|
||||
);
|
||||
}
|
||||
response.contentLength = size == 0 ? 0 : end - start + 1;
|
||||
if (request.method == 'GET' && size > 0) {
|
||||
await response.addStream(
|
||||
File(resource.canonicalPath).openRead(start, end + 1),
|
||||
);
|
||||
}
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
// Never expose filesystem paths or capability tokens to a disconnected peer.
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {
|
||||
/* Socket already closed. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _extension(File file) =>
|
||||
RegExp(r'\.[A-Za-z0-9]{1,8}$')
|
||||
.firstMatch(file.path)
|
||||
?.group(0)
|
||||
?.toLowerCase() ??
|
||||
'';
|
||||
static String _contentType(File file) => switch (_extension(file)) {
|
||||
'.mp4' || '.m4v' => 'video/mp4',
|
||||
'.mkv' => 'video/x-matroska',
|
||||
'.webm' => 'video/webm',
|
||||
'.m3u8' => 'application/vnd.apple.mpegurl',
|
||||
'.mpd' => 'application/dash+xml',
|
||||
'.ts' => 'video/mp2t',
|
||||
'.m4s' => 'video/iso.segment',
|
||||
'.aac' => 'audio/aac',
|
||||
'.mp3' => 'audio/mpeg',
|
||||
'.vtt' => 'text/vtt; charset=utf-8',
|
||||
'.srt' => 'text/plain; charset=utf-8',
|
||||
'.ttml' || '.xml' => 'application/ttml+xml',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
static bool _privateV4(InternetAddress address) {
|
||||
if (address.type != InternetAddressType.IPv4) return false;
|
||||
final b = address.rawAddress;
|
||||
return b[0] == 10 ||
|
||||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
|
||||
(b[0] == 192 && b[1] == 168);
|
||||
}
|
||||
|
||||
static Future<InternetAddress> _discoverAddress() async {
|
||||
final interfaces = await NetworkInterface.list(
|
||||
type: InternetAddressType.IPv4,
|
||||
);
|
||||
for (final interface in interfaces) {
|
||||
if (!RegExp(
|
||||
r'^(wlan|wifi|eth|en)',
|
||||
caseSensitive: false,
|
||||
).hasMatch(interface.name)) {
|
||||
continue;
|
||||
}
|
||||
for (final address in interface.addresses) {
|
||||
if (_privateV4(address)) return address;
|
||||
}
|
||||
}
|
||||
throw StateError(
|
||||
'Connect to a private Wi-Fi or Ethernet network to share offline media.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RegisteredMedia {
|
||||
const _RegisteredMedia(this.source, this.canonicalPath, this.stat);
|
||||
final File source;
|
||||
final String canonicalPath;
|
||||
final FileStat stat;
|
||||
static Future<_RegisteredMedia> create(File file) async {
|
||||
final source = file.absolute;
|
||||
try {
|
||||
final path = await source.resolveSymbolicLinks();
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type != FileSystemEntityType.file) {
|
||||
throw ArgumentError('Select an existing media file.');
|
||||
}
|
||||
return _RegisteredMedia(source, path, stat);
|
||||
} on FileSystemException {
|
||||
throw ArgumentError('The selected media file is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> unchanged() async {
|
||||
try {
|
||||
if (await source.resolveSymbolicLinks() != canonicalPath) return false;
|
||||
final current = await File(canonicalPath).stat();
|
||||
return current.type == FileSystemEntityType.file &&
|
||||
current.size == stat.size &&
|
||||
current.modified == stat.modified;
|
||||
} on FileSystemException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'segmented_download.dart';
|
||||
import '../data/content_policy.dart';
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
/// The native multipart worker starts with HEAD, so only opt into it when
|
||||
/// that exact capability is advertised. A failed/unsupported probe is not a
|
||||
/// failed download: the caller schedules an ordinary streaming GET instead.
|
||||
Future<bool> supportsParallelDownload(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null ||
|
||||
!{'http', 'https'}.contains(uri.scheme) ||
|
||||
uri.host.isEmpty ||
|
||||
!ContentPolicy.allowsUrl(url)) {
|
||||
return false;
|
||||
}
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 3);
|
||||
try {
|
||||
final request = await client
|
||||
.openUrl('HEAD', uri)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
|
||||
final response = await request.close().timeout(const Duration(seconds: 4));
|
||||
return response.statusCode == HttpStatus.ok &&
|
||||
response.contentLength > 0 &&
|
||||
(response.headers[HttpHeaders.acceptRangesHeader] ?? const <String>[])
|
||||
.expand((value) => value.toLowerCase().split(','))
|
||||
.any((value) => value.trim() == 'bytes');
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> exists(String path) async =>
|
||||
await File(path).exists() && await File(path).length() > 0;
|
||||
Future<int> size(String path) => File(path).length();
|
||||
Future<void> delete(String path) async {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
|
||||
const _segmentGroup = 'reelbox.offline.resources';
|
||||
Future<SegmentedDownload>? _packageEngine;
|
||||
final _nativeTasks = <String, Set<String>>{};
|
||||
final _activePackages = <String>{};
|
||||
Future<SegmentedDownload> _engine() => _packageEngine ??= () async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
return SegmentedDownload(
|
||||
root: Directory('${support.path}/offline_packages'),
|
||||
transfer: _nativeTransfer,
|
||||
cancelTransfers: _cancelTransfers,
|
||||
);
|
||||
}();
|
||||
|
||||
Future<void> _cancelTransfers(String id) async {
|
||||
final ids = _nativeTasks[id]?.toList() ?? <String>[];
|
||||
if (ids.isNotEmpty && !await FileDownloader().cancelTasksWithIds(ids)) {
|
||||
throw StateError('Android could not cancel an offline resource task.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _nativeTransfer(
|
||||
OfflinePackage p,
|
||||
OfflineResource r,
|
||||
String destination,
|
||||
) async {
|
||||
final original = Task.createFromJson(
|
||||
Map<String, dynamic>.from(p.metadata['task'] as Map),
|
||||
);
|
||||
final metadata = original.metaData;
|
||||
final meta = Map<String, dynamic>.from(jsonDecode(metadata) as Map);
|
||||
if (!ContentPolicy.allowsTitleJson({
|
||||
'title': meta['title'],
|
||||
'type': meta['titleKind'],
|
||||
}) ||
|
||||
!ContentPolicy.allowsText(meta['episodeTitle'] as String? ?? '') ||
|
||||
!ContentPolicy.allowsUrl(r.url)) {
|
||||
throw UnsupportedError(
|
||||
'Excluded offline metadata cannot start a native job.',
|
||||
);
|
||||
}
|
||||
final id = '${p.id}_${r.filename}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
final enabled = (p.metadata['notifications'] as bool?) ?? false;
|
||||
final task = DownloadTask(
|
||||
taskId: id,
|
||||
url: r.url,
|
||||
headers: {
|
||||
'Accept-Encoding': 'identity',
|
||||
if (r.offset != null)
|
||||
'Range': 'bytes=${r.offset}-${r.offset! + r.length! - 1}',
|
||||
},
|
||||
// Per-run names keep orphan OS writers away from the resumed destination.
|
||||
filename: '${File(destination).uri.pathSegments.last}.$id.part',
|
||||
directory: File(destination).parent.path,
|
||||
baseDirectory: BaseDirectory.root,
|
||||
group: _segmentGroup,
|
||||
requiresWiFi: original.requiresWiFi,
|
||||
retries: 2,
|
||||
allowPause: false,
|
||||
updates: Updates.statusAndProgress,
|
||||
displayName: original.displayName,
|
||||
metaData: metadata,
|
||||
notificationConfig: enabled
|
||||
? TaskNotificationConfig(
|
||||
running: const TaskNotification(
|
||||
'Reelbox • saving offline resource',
|
||||
'{displayName}\n{progress}',
|
||||
),
|
||||
error: const TaskNotification(
|
||||
'Reelbox • offline resource failed',
|
||||
'{displayName}',
|
||||
),
|
||||
progressBar: true,
|
||||
tapOpensFile: false,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
(_nativeTasks[p.id] ??= <String>{}).add(id);
|
||||
try {
|
||||
final update = await FileDownloader().download(task);
|
||||
if (update.status != TaskStatus.complete) {
|
||||
throw StateError('Native resource download ${update.status.name}.');
|
||||
}
|
||||
final code = update.responseStatusCode;
|
||||
if (r.offset != null) {
|
||||
final range = update.responseHeaders?['content-range'] ?? '';
|
||||
if (code != 206 ||
|
||||
!RegExp('^bytes ${r.offset}-${r.offset! + r.length! - 1}/[0-9]+\$')
|
||||
.hasMatch(range)) {
|
||||
throw const FormatException(
|
||||
'Server did not honor the resource byte range.',
|
||||
);
|
||||
}
|
||||
} else if (code != 200) {
|
||||
throw StateError('Native resource HTTP $code.');
|
||||
}
|
||||
final expected = int.tryParse(
|
||||
update.responseHeaders?['content-length'] ?? '',
|
||||
);
|
||||
final downloaded = File(await task.filePath());
|
||||
if (expected != null && expected != await downloaded.length()) {
|
||||
throw const FormatException('Truncated native resource.');
|
||||
}
|
||||
await downloaded.rename(destination);
|
||||
} finally {
|
||||
_nativeTasks[p.id]?.remove(id);
|
||||
await FileDownloader().database.deleteRecordWithId(id);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _snapshot(OfflinePackage p) => {
|
||||
...p.toJson(),
|
||||
'path': p.path,
|
||||
'directory': p.directory,
|
||||
'receivedBytes': p.receivedBytes,
|
||||
'progress': p.progress,
|
||||
};
|
||||
Future<List<Map<String, dynamic>>> recoverPackages() async {
|
||||
// Interrupted child jobs must not race a newly resumed writer to the same .part.
|
||||
final downloader = FileDownloader();
|
||||
final tasks = await downloader.allTasks(group: _segmentGroup);
|
||||
final orphans = tasks
|
||||
.where((t) => !_nativeTasks.values.any((ids) => ids.contains(t.taskId)))
|
||||
.toList();
|
||||
if (orphans.isNotEmpty &&
|
||||
!await downloader.cancelTasksWithIds(
|
||||
orphans.map((t) => t.taskId).toList(),
|
||||
)) {
|
||||
throw StateError(
|
||||
'Cannot recover while Android still owns an old resource job.',
|
||||
);
|
||||
}
|
||||
return (await (await _engine()).recover()).map(_snapshot).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> preparePackage({
|
||||
required String id,
|
||||
required String url,
|
||||
required String type,
|
||||
required Map<String, dynamic> metadata,
|
||||
required int parallel,
|
||||
}) async {
|
||||
final engine = await _engine();
|
||||
engine.parallel = parallel.clamp(1, 8);
|
||||
return _snapshot(
|
||||
await engine.prepare(id: id, url: url, type: type, metadata: metadata),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> runPackage(
|
||||
String id,
|
||||
void Function(Map<String, dynamic>) onProgress,
|
||||
) async {
|
||||
if (!_activePackages.add(id)) {
|
||||
throw StateError('Offline package is already active.');
|
||||
}
|
||||
try {
|
||||
await (await _engine()).run(
|
||||
id,
|
||||
onProgress: (p) => onProgress(_snapshot(p)),
|
||||
);
|
||||
} finally {
|
||||
_activePackages.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pausePackage(String id, {bool cancel = false}) async =>
|
||||
(await _engine()).stop(id, cancel: cancel);
|
||||
Future<void> deletePackage(String id) async => (await _engine()).delete(id);
|
||||
Future<String> exportPackage(String id) async => (await _engine()).export(id);
|
||||
Future<bool> validatePackage(String id) async => (await _engine()).validate(id);
|
||||
Future<Map<String, dynamic>> loadPackage(String id) async =>
|
||||
_snapshot(await (await _engine()).load(id));
|
||||
@@ -0,0 +1,26 @@
|
||||
Future<bool> supportsParallelDownload(String url) async => false;
|
||||
Future<bool> exists(String path) async => false;
|
||||
Future<int> size(String path) async => 0;
|
||||
Future<void> delete(String path) async =>
|
||||
throw UnsupportedError('Native files are unavailable in the browser.');
|
||||
Future<List<Map<String, dynamic>>> recoverPackages() async => [];
|
||||
Future<Map<String, dynamic>> preparePackage({
|
||||
required String id,
|
||||
required String url,
|
||||
required String type,
|
||||
required Map<String, dynamic> metadata,
|
||||
required int parallel,
|
||||
}) async => throw UnsupportedError('Offline packages require the Android app.');
|
||||
Future<void> runPackage(
|
||||
String id,
|
||||
void Function(Map<String, dynamic>) onProgress,
|
||||
) async => throw UnsupportedError('Offline packages require the Android app.');
|
||||
Future<void> pausePackage(String id, {bool cancel = false}) async =>
|
||||
throw UnsupportedError('Offline packages require the Android app.');
|
||||
Future<void> deletePackage(String id) async =>
|
||||
throw UnsupportedError('Offline packages require the Android app.');
|
||||
Future<String> exportPackage(String id) async =>
|
||||
throw UnsupportedError('Offline packages require the Android app.');
|
||||
Future<bool> validatePackage(String id) async => false;
|
||||
Future<Map<String, dynamic>> loadPackage(String id) async =>
|
||||
throw UnsupportedError('Offline packages require the Android app.');
|
||||
@@ -0,0 +1,443 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../state/app_state.dart';
|
||||
|
||||
/// Orchestrates pending player work across route disposal/source changes.
|
||||
class PlaybackLifecycle {
|
||||
PlaybackLifecycle({this.isMounted});
|
||||
final bool Function()? isMounted;
|
||||
bool _disposed = false;
|
||||
int _openGeneration = 0;
|
||||
// Platform/window state is shared by every player route, not just a widget.
|
||||
static Future<void>? _systemTail;
|
||||
static PlaybackLifecycle? _systemOwner;
|
||||
bool get ownsSystem => _active && identical(_systemOwner, this);
|
||||
bool get _active => !_disposed && (isMounted?.call() ?? true);
|
||||
|
||||
static Future<T> _serialize<T>(Future<T> Function() action) {
|
||||
final previous = _systemTail;
|
||||
// Drop completed futures when idle, rather than keeping a previous player's
|
||||
// scheduling zone alive. Work is still serialized across active routes.
|
||||
final work = previous == null
|
||||
? Future<T>.microtask(action)
|
||||
: previous.then((_) => action());
|
||||
late final Future<void> tail;
|
||||
tail = work
|
||||
.then<void>((_) {}, onError: (Object _, StackTrace _) {})
|
||||
.whenComplete(() {
|
||||
if (identical(_systemTail, tail)) _systemTail = null;
|
||||
});
|
||||
_systemTail = tail;
|
||||
return work;
|
||||
}
|
||||
|
||||
Future<bool> setup(List<Future<void> Function()> steps) {
|
||||
// Capture ownership before even awaiting the system-work queue.
|
||||
final activeAtStart = _active;
|
||||
return _serialize(() async {
|
||||
if (!activeAtStart || !_active) return false;
|
||||
_systemOwner = this;
|
||||
for (final step in steps) {
|
||||
await step();
|
||||
if (!_active) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> open({
|
||||
required Future<void> Function() save,
|
||||
required Future<void> Function(bool Function() current) dispatch,
|
||||
}) async {
|
||||
final generation = ++_openGeneration;
|
||||
bool current() => _active && generation == _openGeneration;
|
||||
if (!current()) return false;
|
||||
await save();
|
||||
if (!current()) return false;
|
||||
await dispatch(current);
|
||||
return current();
|
||||
}
|
||||
|
||||
Future<void> dispose(Future<void> Function() restore) {
|
||||
_disposed = true;
|
||||
_openGeneration++;
|
||||
// A previous route must never restore global state over a newer owner.
|
||||
return _serialize(() async {
|
||||
if (!identical(_systemOwner, this)) return;
|
||||
await restore();
|
||||
if (identical(_systemOwner, this)) _systemOwner = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispatchMediaOpen({
|
||||
required Future<void> Function(Media media, {bool play}) open,
|
||||
required String uri,
|
||||
required Duration start,
|
||||
required bool autoplay,
|
||||
}) => open(Media(uri, start: start), play: autoplay);
|
||||
|
||||
/// Keep startup/failed-open zero state out of durable resume history.
|
||||
class PlaybackCheckpoint {
|
||||
Duration position = Duration.zero;
|
||||
Duration duration = Duration.zero;
|
||||
bool _opened = false;
|
||||
bool get canPersist => _opened && position > Duration.zero;
|
||||
void beginOpen() {
|
||||
_opened = false;
|
||||
position = Duration.zero;
|
||||
duration = Duration.zero;
|
||||
}
|
||||
|
||||
void didOpen() {
|
||||
_opened = true;
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeRecord? nextEpisode(List<EpisodeRecord> episodes, int? currentId) {
|
||||
final index = episodes.indexWhere((episode) => episode.id == currentId);
|
||||
return index >= 0 && index + 1 < episodes.length ? episodes[index + 1] : null;
|
||||
}
|
||||
|
||||
String watchKey(int titleId, int? episodeId) =>
|
||||
'$titleId:${episodeId ?? 'movie'}';
|
||||
|
||||
Duration resumePosition({
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
int rewindSeconds = 0,
|
||||
}) {
|
||||
if (position < const Duration(seconds: 5)) return Duration.zero;
|
||||
if (duration > Duration.zero &&
|
||||
(position >= duration - const Duration(seconds: 15) ||
|
||||
position.inMilliseconds / duration.inMilliseconds >= .95)) {
|
||||
return Duration.zero;
|
||||
}
|
||||
return Duration(
|
||||
milliseconds: (position.inMilliseconds - rewindSeconds.clamp(0, 120) * 1000)
|
||||
.clamp(0, position.inMilliseconds),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared by enqueue, retry and external handoff: manifest aliases must never
|
||||
/// fall through to a direct-file job, including extensionless MPD endpoints.
|
||||
bool isSegmentedMediaSource(MediaSource source) {
|
||||
final path = Uri.tryParse(source.url)?.path.toLowerCase() ?? '';
|
||||
final type = source.type.split(';').first.trim().toLowerCase();
|
||||
return path.endsWith('.m3u8') ||
|
||||
path.endsWith('.mpd') ||
|
||||
type == 'mpd' ||
|
||||
RegExp(r'hls|dash|m3u8|mpegurl').hasMatch(type);
|
||||
}
|
||||
|
||||
String? downloadUnsupportedReason(MediaSource source) {
|
||||
if (!isRemoteMedia(source.url)) {
|
||||
return 'Only public HTTP(S) files can be downloaded.';
|
||||
}
|
||||
if (isSegmentedMediaSource(source)) {
|
||||
return 'Use offline HLS/DASH segment packaging instead of a direct video file.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String downloadFilename({
|
||||
required String title,
|
||||
required String identity,
|
||||
required String url,
|
||||
}) {
|
||||
var name = title
|
||||
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), '_')
|
||||
.replaceAll(RegExp(r'^\.+_?'), '')
|
||||
.trim();
|
||||
if (name.isEmpty) name = 'Video';
|
||||
if (name.length > 90) name = name.substring(0, 90);
|
||||
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
|
||||
final ext =
|
||||
RegExp(r'\.(mp4|mkv|webm|mov|avi|m4v|ts)$').firstMatch(path)?.group(1) ??
|
||||
'mp4';
|
||||
return '${name}_$identity.$ext';
|
||||
}
|
||||
|
||||
bool isRemoteMedia(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
return uri != null &&
|
||||
(uri.scheme == 'https' || uri.scheme == 'http') &&
|
||||
uri.host.isNotEmpty;
|
||||
}
|
||||
|
||||
MediaSource? selectSource(
|
||||
Iterable<MediaSource> sources, {
|
||||
String preferredQuality = '',
|
||||
}) {
|
||||
final options = playableSources(sources);
|
||||
if (options.isEmpty) return null;
|
||||
return options.firstWhere(
|
||||
(s) => s.quality == preferredQuality,
|
||||
orElse: () => options.first,
|
||||
);
|
||||
}
|
||||
|
||||
List<MediaSource> playableSources(Iterable<MediaSource> sources) {
|
||||
final seen = <String>{};
|
||||
return sources
|
||||
.where((s) => !s.isTrailer && isRemoteMedia(s.url) && seen.add(s.url))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Strict Unicode first; legacy Persian/Arabic files commonly use Windows-1256.
|
||||
String decodeSubtitle(List<int> bytes) {
|
||||
if (bytes.length >= 2 &&
|
||||
((bytes[0] == 255 && bytes[1] == 254) ||
|
||||
(bytes[0] == 254 && bytes[1] == 255))) {
|
||||
final little = bytes[0] == 255;
|
||||
if (bytes.length.isOdd) {
|
||||
throw const FormatException('Truncated UTF-16 subtitle');
|
||||
}
|
||||
return String.fromCharCodes([
|
||||
for (var i = 2; i + 1 < bytes.length; i += 2)
|
||||
little ? bytes[i] | bytes[i + 1] << 8 : bytes[i] << 8 | bytes[i + 1],
|
||||
]);
|
||||
}
|
||||
try {
|
||||
return utf8.decode(bytes).replaceFirst(RegExp(r'^\uFEFF'), '');
|
||||
} on FormatException {
|
||||
return String.fromCharCodes(
|
||||
bytes.map((b) => b < 128 ? b : _windows1256[b - 128]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode mapping of the Windows-1256 high-byte range (not Latin-1).
|
||||
const _windows1256 = [
|
||||
8364,
|
||||
1662,
|
||||
8218,
|
||||
402,
|
||||
8222,
|
||||
8230,
|
||||
8224,
|
||||
8225,
|
||||
710,
|
||||
8240,
|
||||
1657,
|
||||
8249,
|
||||
338,
|
||||
1670,
|
||||
1688,
|
||||
1672,
|
||||
1711,
|
||||
8216,
|
||||
8217,
|
||||
8220,
|
||||
8221,
|
||||
8226,
|
||||
8211,
|
||||
8212,
|
||||
1705,
|
||||
8482,
|
||||
1681,
|
||||
8250,
|
||||
339,
|
||||
8204,
|
||||
8205,
|
||||
1722,
|
||||
160,
|
||||
1548,
|
||||
162,
|
||||
163,
|
||||
164,
|
||||
165,
|
||||
166,
|
||||
167,
|
||||
168,
|
||||
169,
|
||||
1726,
|
||||
171,
|
||||
172,
|
||||
173,
|
||||
174,
|
||||
175,
|
||||
176,
|
||||
177,
|
||||
178,
|
||||
179,
|
||||
180,
|
||||
181,
|
||||
182,
|
||||
183,
|
||||
184,
|
||||
185,
|
||||
1563,
|
||||
187,
|
||||
188,
|
||||
189,
|
||||
190,
|
||||
1567,
|
||||
1729,
|
||||
1569,
|
||||
1570,
|
||||
1571,
|
||||
1572,
|
||||
1573,
|
||||
1574,
|
||||
1575,
|
||||
1576,
|
||||
1577,
|
||||
1578,
|
||||
1579,
|
||||
1580,
|
||||
1581,
|
||||
1582,
|
||||
1583,
|
||||
1584,
|
||||
1585,
|
||||
1586,
|
||||
1587,
|
||||
1588,
|
||||
1589,
|
||||
1590,
|
||||
215,
|
||||
1591,
|
||||
1592,
|
||||
1593,
|
||||
1594,
|
||||
1600,
|
||||
1601,
|
||||
1602,
|
||||
1603,
|
||||
224,
|
||||
1604,
|
||||
226,
|
||||
1605,
|
||||
1606,
|
||||
1607,
|
||||
1608,
|
||||
231,
|
||||
232,
|
||||
233,
|
||||
234,
|
||||
235,
|
||||
1609,
|
||||
1610,
|
||||
238,
|
||||
239,
|
||||
1611,
|
||||
1612,
|
||||
1613,
|
||||
1614,
|
||||
244,
|
||||
1615,
|
||||
1616,
|
||||
247,
|
||||
1617,
|
||||
249,
|
||||
1618,
|
||||
251,
|
||||
252,
|
||||
8206,
|
||||
8207,
|
||||
1746,
|
||||
];
|
||||
|
||||
class SubtitleVariant {
|
||||
const SubtitleVariant(this.language, this.label, this.url);
|
||||
final String language, label, url;
|
||||
}
|
||||
|
||||
/// Accept flat lists as well as language groups containing release variants.
|
||||
Map<String, List<SubtitleVariant>> subtitleGroups(
|
||||
List<Map<String, dynamic>> rows,
|
||||
) {
|
||||
final result = <String, List<SubtitleVariant>>{};
|
||||
final seen = <String>{};
|
||||
String text(dynamic value) => value is Map
|
||||
? '${value['name'] ?? value['title'] ?? value['code'] ?? ''}'
|
||||
: value is String
|
||||
? value
|
||||
: '';
|
||||
void visit(Map item, String inherited) {
|
||||
final lang = text(item['language']).trim();
|
||||
final language = lang.isEmpty ? inherited : lang;
|
||||
final url = text(item['url'] ?? item['link']);
|
||||
if (isRemoteMedia(url) && seen.add(url)) {
|
||||
final label = text(item['title'] ?? item['name'] ?? item['quality']);
|
||||
(result[language] ??= []).add(
|
||||
SubtitleVariant(language, label.isEmpty ? 'Subtitle' : label, url),
|
||||
);
|
||||
}
|
||||
for (final key in ['subtitles', 'variants', 'files', 'releases', 'items']) {
|
||||
final children = item[key];
|
||||
if (children is List) {
|
||||
for (final child in children.whereType<Map>()) {
|
||||
visit(child, language);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final item in rows) {
|
||||
visit(item, 'Other');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> applyPlayerOptions(
|
||||
AppSettings prefs, {
|
||||
required Future<void> Function(double) rate,
|
||||
required Future<void> Function(bool) audioOnly,
|
||||
required Future<void> Function(double) volume,
|
||||
bool Function()? current,
|
||||
}) async {
|
||||
if (current != null && !current()) return;
|
||||
await rate(prefs.playbackSpeed);
|
||||
if (current != null && !current()) return;
|
||||
await audioOnly(prefs.audioOnly);
|
||||
if (current != null && !current()) return;
|
||||
if (prefs.rememberVolume) await volume(prefs.playerVolume);
|
||||
}
|
||||
|
||||
AppSettings resetSubtitleSettings(AppSettings prefs) {
|
||||
final defaults = AppSettings();
|
||||
return prefs.copyWith(
|
||||
subtitleEnabled: defaults.subtitleEnabled,
|
||||
subtitleFontFamily: defaults.subtitleFontFamily,
|
||||
subtitleFontSize: defaults.subtitleFontSize,
|
||||
subtitleColor: defaults.subtitleColor,
|
||||
subtitleBackground: defaults.subtitleBackground,
|
||||
subtitleDelay: defaults.subtitleDelay,
|
||||
);
|
||||
}
|
||||
|
||||
AppSettings resetPlayerSettings(AppSettings prefs) {
|
||||
final d = AppSettings();
|
||||
return resetSubtitleSettings(prefs).copyWith(
|
||||
playbackSpeed: d.playbackSpeed,
|
||||
audioDelay: d.audioDelay,
|
||||
videoFit: d.videoFit,
|
||||
audioOnly: d.audioOnly,
|
||||
playerVolume: d.playerVolume,
|
||||
rememberVolume: d.rememberVolume,
|
||||
backgroundAudio: d.backgroundAudio,
|
||||
closeOnCompletion: d.closeOnCompletion,
|
||||
resumeRewindSeconds: d.resumeRewindSeconds,
|
||||
skipSeconds: d.skipSeconds,
|
||||
resumePlayback: d.resumePlayback,
|
||||
autoplay: d.autoplay,
|
||||
autoNext: d.autoNext,
|
||||
orientation: d.orientation,
|
||||
externalPlayer: d.externalPlayer,
|
||||
externalPlayerPackage: d.externalPlayerPackage,
|
||||
);
|
||||
}
|
||||
|
||||
String mediaContentType(String url) {
|
||||
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
|
||||
if (path.endsWith('.m3u8')) return 'application/x-mpegURL';
|
||||
if (path.endsWith('.mpd')) return 'application/dash+xml';
|
||||
if (path.endsWith('.webm')) return 'video/webm';
|
||||
if (path.endsWith('.mkv')) return 'video/x-matroska';
|
||||
return 'video/mp4';
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Captures Android controls on entry and restores the activity's brightness
|
||||
/// override on exit. System volume is intentionally not restored.
|
||||
class PlayerSystemControls {
|
||||
double brightness = .5, volume = .5;
|
||||
// The activity baseline belongs to the shared player-control session. A
|
||||
// replacement route must not adopt the previous route's override as baseline.
|
||||
static double? _originalBrightness;
|
||||
Future<void> capture() async {
|
||||
if (!MediaPlatform.supported) return;
|
||||
final original = await MediaPlatform.invoke<double>('getBrightness');
|
||||
final effective = original != null && original >= 0
|
||||
? original
|
||||
: await MediaPlatform.invoke<double>('getEffectiveBrightness');
|
||||
final currentVolume = await MediaPlatform.invoke<double>('getVolume');
|
||||
_originalBrightness ??= original ?? -1;
|
||||
if (effective != null && effective.isFinite) {
|
||||
brightness = effective.clamp(0, 1);
|
||||
}
|
||||
if (currentVolume != null && currentVolume.isFinite) {
|
||||
volume = currentVolume.clamp(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restore() async {
|
||||
final original = _originalBrightness;
|
||||
_originalBrightness = null;
|
||||
if (original != null && MediaPlatform.supported) {
|
||||
await MediaPlatform.invoke<void>('setBrightness', {'value': original});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Android channel implemented by MainActivity; no synthetic platform success.
|
||||
class MediaPlatform {
|
||||
static const channel = MethodChannel('org.reelbox/media');
|
||||
static bool get supported =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
static Future<T?> invoke<T>(
|
||||
String method, [
|
||||
Map<String, dynamic>? args,
|
||||
]) async {
|
||||
if (!supported) {
|
||||
throw UnsupportedError('This control is available on Android only.');
|
||||
}
|
||||
return channel.invokeMethod<T>(method, args);
|
||||
}
|
||||
|
||||
static Future<bool> enterPip() async =>
|
||||
await invoke<bool>('enterPip') ?? false;
|
||||
static Future<void> setActive(bool active) async {
|
||||
if (supported) await invoke<void>('setPlayerActive', {'active': active});
|
||||
}
|
||||
|
||||
static Future<void> openExternal(
|
||||
String url, {
|
||||
String packageName = 'ask',
|
||||
String? title,
|
||||
String? subtitleUrl,
|
||||
Duration? position,
|
||||
}) async {
|
||||
final ok = await invoke<bool>('openExternal', {
|
||||
'url': url,
|
||||
'packageName': packageName,
|
||||
'title': ?title,
|
||||
'subtitleUrl': ?subtitleUrl,
|
||||
if (position != null) 'positionMs': position.inMilliseconds,
|
||||
});
|
||||
if (ok != true) {
|
||||
throw StateError(
|
||||
'No installed external video player can open this source.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-owner notification/Cast bridge. The local media_kit player remains the
|
||||
/// decoder; the Android foreground service never opens a second audio stream.
|
||||
class PlayerNativeSession {
|
||||
PlayerNativeSession({
|
||||
required this.invoke,
|
||||
required this.local,
|
||||
required this.media,
|
||||
required this.isPlaying,
|
||||
required this.isCurrent,
|
||||
this.changed,
|
||||
this.failure,
|
||||
this.prepareMedia,
|
||||
this.revokeMedia,
|
||||
this._castRequested = false,
|
||||
});
|
||||
Future<dynamic> Function(String, Map<String, dynamic>?) invoke;
|
||||
final Future<void> Function(String, Duration?) local;
|
||||
final Map<String, dynamic> Function() media;
|
||||
final bool Function() isPlaying, isCurrent;
|
||||
final void Function()? changed;
|
||||
final void Function(String)? failure;
|
||||
final Future<Map<String, dynamic>> Function()? prepareMedia;
|
||||
final Future<void> Function()? revokeMedia;
|
||||
bool _castRequested;
|
||||
static PlayerNativeSession? _owner;
|
||||
bool _released = false,
|
||||
_loading = false,
|
||||
_background = false,
|
||||
_stopped = false;
|
||||
bool _castAttempted = false;
|
||||
Future<void>? _loadTail;
|
||||
bool? _loadPlayState;
|
||||
int _castRevision = 0;
|
||||
int? _disconnectedRevision;
|
||||
bool casting = false, connected = false, playing = false, inPip = false;
|
||||
Duration position = Duration.zero, duration = Duration.zero;
|
||||
String deviceName = '';
|
||||
bool get active => !_released && identical(_owner, this) && isCurrent();
|
||||
void claim() {
|
||||
_owner = this;
|
||||
MediaPlatform.channel.setMethodCallHandler(handle);
|
||||
}
|
||||
|
||||
void release() {
|
||||
_released = true;
|
||||
_disconnectedRevision = null;
|
||||
_castRevision++;
|
||||
unawaited(_revoke().catchError((Object _) {}));
|
||||
if (identical(_owner, this)) {
|
||||
_owner = null;
|
||||
MediaPlatform.channel.setMethodCallHandler(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handle(MethodCall call) async {
|
||||
if (!active) return;
|
||||
if (call.method == 'pipChanged') {
|
||||
inPip = call.arguments == true;
|
||||
changed?.call();
|
||||
return;
|
||||
}
|
||||
if (call.arguments is! Map) return;
|
||||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||||
if (call.method == 'mediaAction') {
|
||||
final action = args['action'];
|
||||
if (action is String &&
|
||||
const [
|
||||
'play',
|
||||
'pause',
|
||||
'seek',
|
||||
'stop',
|
||||
'next',
|
||||
'previous',
|
||||
].contains(action)) {
|
||||
final ms = args['positionMs'];
|
||||
if (action == 'seek' && (ms is! num || !ms.isFinite || ms < 0)) return;
|
||||
await command(
|
||||
action,
|
||||
position: ms is num ? Duration(milliseconds: ms.toInt()) : null,
|
||||
);
|
||||
}
|
||||
} else if (call.method == 'castChanged') {
|
||||
final wasConnected = connected;
|
||||
connected = args['connected'] == true;
|
||||
deviceName = args['deviceName'] is String
|
||||
? args['deviceName'] as String
|
||||
: deviceName;
|
||||
if (!connected) {
|
||||
_castAttempted = false;
|
||||
if (wasConnected) _disconnectedRevision = _castRevision;
|
||||
_castRevision++;
|
||||
await _revoke();
|
||||
if (casting) {
|
||||
final resume = playing;
|
||||
casting = false;
|
||||
await local('seek', position);
|
||||
if (!active) return;
|
||||
await local(resume ? 'play' : 'pause', null);
|
||||
}
|
||||
} else if (_castRequested && !casting && !_loading && !_castAttempted) {
|
||||
await loadCast();
|
||||
} else if (casting) {
|
||||
playing = args['playing'] == true;
|
||||
final p = args['positionMs'], d = args['durationMs'];
|
||||
if (p is num && p.isFinite && p >= 0) {
|
||||
position = Duration(milliseconds: p.toInt());
|
||||
}
|
||||
if (d is num && d.isFinite && d >= 0) {
|
||||
duration = Duration(milliseconds: d.toInt());
|
||||
}
|
||||
}
|
||||
if (active) changed?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel snapshots synchronously, before a source/subtitle change awaits.
|
||||
/// This does not grant sharing consent or enqueue a replacement transfer.
|
||||
Future<void> invalidateCastMedia() {
|
||||
_castRevision++;
|
||||
_disconnectedRevision = null;
|
||||
return _revoke();
|
||||
}
|
||||
|
||||
Future<void> loadCast() {
|
||||
if (!active) return Future<void>.value();
|
||||
_disconnectedRevision = null;
|
||||
_castAttempted = true;
|
||||
_castRequested = true;
|
||||
final revision = ++_castRevision;
|
||||
_loadPlayState ??= isPlaying();
|
||||
final previous = _loadTail;
|
||||
final work = previous == null
|
||||
? Future<void>.microtask(() => _runCastLoad(revision))
|
||||
: previous.then((_) => _runCastLoad(revision));
|
||||
late final Future<void> tail;
|
||||
tail = work.catchError((Object _) {}).whenComplete(() {
|
||||
if (identical(_loadTail, tail)) {
|
||||
_loadTail = null;
|
||||
_loadPlayState = null;
|
||||
}
|
||||
});
|
||||
_loadTail = tail;
|
||||
return work;
|
||||
}
|
||||
|
||||
Future<void> _runCastLoad(int revision) async {
|
||||
if (!active || revision != _castRevision || !connected) return;
|
||||
_loading = true;
|
||||
final wasPlaying = _loadPlayState ?? isPlaying();
|
||||
var paused = false;
|
||||
try {
|
||||
final payload = Map<String, dynamic>.from(
|
||||
prepareMedia == null ? media() : await prepareMedia!(),
|
||||
);
|
||||
if (!active || revision != _castRevision) {
|
||||
await _revoke();
|
||||
return;
|
||||
}
|
||||
await local('pause', null);
|
||||
paused = true;
|
||||
if (!active || revision != _castRevision) {
|
||||
await _revoke();
|
||||
await _restoreDisconnectedLoad(revision);
|
||||
return;
|
||||
}
|
||||
final ok = await invoke('castLoad', payload);
|
||||
if (!active) return;
|
||||
if (revision != _castRevision) {
|
||||
if (_stopped) await invoke('castCommand', {'action': 'stop'});
|
||||
await _revoke();
|
||||
await _restoreDisconnectedLoad(revision);
|
||||
return;
|
||||
}
|
||||
if (ok != true) throw StateError('Cast load rejected');
|
||||
casting = true;
|
||||
playing = _loadPlayState ?? wasPlaying;
|
||||
position = Duration(
|
||||
milliseconds: (payload['positionMs'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
if (!playing) await invoke('castCommand', {'action': 'pause'});
|
||||
// A remote session must not also advertise a local background session.
|
||||
if (_background) {
|
||||
await invoke('backgroundPlaybackStop', null);
|
||||
_background = false;
|
||||
}
|
||||
} catch (error) {
|
||||
await _revoke();
|
||||
if (!active || revision != _castRevision) {
|
||||
if (paused) await _restoreDisconnectedLoad(revision);
|
||||
return;
|
||||
}
|
||||
casting = false;
|
||||
if (paused) {
|
||||
await local((_loadPlayState ?? wasPlaying) ? 'play' : 'pause', null);
|
||||
}
|
||||
if (active && revision == _castRevision) {
|
||||
failure?.call(
|
||||
error is FormatException || error is UnsupportedError
|
||||
? 'This media or subtitle format cannot be shared with the Cast device. Local playback restored.'
|
||||
: 'Could not load this video on the Cast device. Local playback restored.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_loading = false;
|
||||
if (active) changed?.call();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _revoke() async {
|
||||
await revokeMedia?.call();
|
||||
}
|
||||
|
||||
Future<void> _restoreDisconnectedLoad(int revision) async {
|
||||
// Only disconnect may restore an interrupted transfer. A newer media load,
|
||||
// stop or owner must never be undone by the old decoder pause completing.
|
||||
if (active &&
|
||||
!connected &&
|
||||
!_stopped &&
|
||||
_disconnectedRevision == revision) {
|
||||
await local((_loadPlayState ?? isPlaying()) ? 'play' : 'pause', null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> command(
|
||||
String action, {
|
||||
Duration? position,
|
||||
double? value,
|
||||
}) async {
|
||||
if (!active) return;
|
||||
if (_loadTail != null && (action == 'play' || action == 'pause')) {
|
||||
_loadPlayState = action == 'play';
|
||||
}
|
||||
if (action == 'play') _stopped = false;
|
||||
if (action == 'stop') {
|
||||
_stopped = true;
|
||||
_disconnectedRevision = null;
|
||||
_castRequested = false;
|
||||
_castRevision++;
|
||||
await stopBackground();
|
||||
if (!active) return;
|
||||
try {
|
||||
if (casting) await invoke('castCommand', {'action': 'stop'});
|
||||
await local('stop', null);
|
||||
} finally {
|
||||
casting = false;
|
||||
await _revoke();
|
||||
if (active) changed?.call();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (casting &&
|
||||
const ['play', 'pause', 'seek', 'stop', 'volume'].contains(action)) {
|
||||
await invoke('castCommand', {
|
||||
'action': action,
|
||||
if (position != null) 'positionMs': position.inMilliseconds,
|
||||
'value': ?value,
|
||||
});
|
||||
} else {
|
||||
await local(action, position);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> picker() async {
|
||||
if (!active) return false;
|
||||
_castRequested = true;
|
||||
return await invoke('castShowPicker', null) == true;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!active) return;
|
||||
_castRequested = false;
|
||||
await invoke('castDisconnect', null);
|
||||
if (active) {
|
||||
await handle(const MethodCall('castChanged', {'connected': false}));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshCast() async {
|
||||
if (!active) return;
|
||||
final state = await invoke('castGetState', null);
|
||||
if (active && state is Map) await handle(MethodCall('castChanged', state));
|
||||
}
|
||||
|
||||
Future<void> background({
|
||||
required bool enabled,
|
||||
required bool playing,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
}) async {
|
||||
if (!active) return;
|
||||
if (!enabled || casting || _stopped) {
|
||||
if (_background) {
|
||||
await invoke('backgroundPlaybackStop', null);
|
||||
_background = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await invoke(
|
||||
_background ? 'backgroundPlaybackUpdate' : 'backgroundPlayback',
|
||||
{
|
||||
if (!_background) 'enabled': true,
|
||||
'title': media()['title'] ?? '',
|
||||
'playing': playing,
|
||||
'positionMs': position.inMilliseconds,
|
||||
'durationMs': duration.inMilliseconds,
|
||||
},
|
||||
);
|
||||
_background = true;
|
||||
}
|
||||
|
||||
Future<void> stopBackground() async {
|
||||
if (_background) {
|
||||
await invoke('backgroundPlaybackStop', null);
|
||||
_background = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/content_policy.dart';
|
||||
import '../data/models.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'cast_media_share.dart';
|
||||
import 'media_helpers.dart';
|
||||
import 'media_platform.dart';
|
||||
import 'native_player.dart';
|
||||
|
||||
/// Android playback uses a standalone LibVLC activity. Its exit future owns the
|
||||
/// checkpoint and any device-only offline-package HTTP lease.
|
||||
class NativePlaybackPage extends StatefulWidget {
|
||||
const NativePlaybackPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.source,
|
||||
this.episode,
|
||||
this.playlist = const [],
|
||||
this.alternatives = const [],
|
||||
this.localFile,
|
||||
});
|
||||
final TitleRecord title;
|
||||
final MediaSource source;
|
||||
final EpisodeRecord? episode;
|
||||
final List<EpisodeRecord> playlist;
|
||||
final List<MediaSource> alternatives;
|
||||
final String? localFile;
|
||||
@override
|
||||
State<NativePlaybackPage> createState() => _NativePlaybackPageState();
|
||||
}
|
||||
|
||||
class _NativePlaybackPageState extends State<NativePlaybackPage> {
|
||||
late MediaSource _source = widget.source;
|
||||
late EpisodeRecord? _episode = widget.episode;
|
||||
AppState? _app;
|
||||
bool _started = false, _opening = false;
|
||||
String? _error;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_app = AppScope.read(context);
|
||||
if (!_started) {
|
||||
_started = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_open());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _current(int generation) => mounted && generation == _generation;
|
||||
Duration _resume(EpisodeRecord? episode) {
|
||||
final app = _app!;
|
||||
final saved = app.history
|
||||
.where(
|
||||
(entry) =>
|
||||
entry.title.libraryKey == widget.title.libraryKey &&
|
||||
entry.episodeId == episode?.id,
|
||||
)
|
||||
.firstOrNull;
|
||||
if (!app.prefs.resumePlayback || saved == null) return Duration.zero;
|
||||
return resumePosition(
|
||||
position: Duration(milliseconds: (saved.positionSeconds * 1000).round()),
|
||||
duration: Duration(milliseconds: (saved.durationSeconds * 1000).round()),
|
||||
rewindSeconds: app.prefs.resumeRewindSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
if (_opening) return;
|
||||
final generation = ++_generation;
|
||||
setState(() {
|
||||
_opening = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
while (_current(generation)) {
|
||||
final source = _source;
|
||||
final episode = _episode;
|
||||
if (!ContentPolicy.allowsTitleJson(widget.title.toJson()) ||
|
||||
!ContentPolicy.allowsUrl(source.url)) {
|
||||
throw StateError('This source is excluded from ReelBox.');
|
||||
}
|
||||
final prefs = _app!.prefs;
|
||||
var target = widget.localFile ?? source.url;
|
||||
CastMediaLease? lease;
|
||||
// Native HLS/DASH reads need a device-only HTTP origin for package
|
||||
// resources. Never bind a LAN interface or relay remote movie content.
|
||||
if (widget.localFile != null &&
|
||||
RegExp(
|
||||
r'\.(m3u8|mpd)$',
|
||||
caseSensitive: false,
|
||||
).hasMatch(widget.localFile!)) {
|
||||
lease = await CastMediaShare.prepare(
|
||||
payload: {'url': source.url},
|
||||
localFile: widget.localFile,
|
||||
bindAddress: InternetAddress.loopbackIPv4,
|
||||
advertisedHost: '127.0.0.1',
|
||||
);
|
||||
|
||||
if (!_current(generation)) {
|
||||
await lease.close();
|
||||
return;
|
||||
}
|
||||
target = lease.payload['url'] as String;
|
||||
}
|
||||
NativePlaybackResult result;
|
||||
try {
|
||||
result = await NativePlayerBridge.open(
|
||||
url: target,
|
||||
title: episode == null
|
||||
? widget.title.title
|
||||
: '${widget.title.title} — ${episode.title}',
|
||||
position: _resume(episode),
|
||||
autoplay: prefs.autoplay,
|
||||
externalPackage: prefs.externalPlayerPackage,
|
||||
orientation: prefs.orientation,
|
||||
videoFit: prefs.videoFit,
|
||||
speed: prefs.playbackSpeed,
|
||||
subtitleEnabled: prefs.subtitleEnabled,
|
||||
);
|
||||
} finally {
|
||||
// The pending native call owns this lease, even if the Flutter route
|
||||
// was removed. Revoke only after the activity has exited.
|
||||
await lease?.close();
|
||||
}
|
||||
if (!_current(generation)) return;
|
||||
// Launch success or an initial zero position is not evidence of viewing.
|
||||
if (result.opened &&
|
||||
result.position > Duration.zero &&
|
||||
!source.isTrailer) {
|
||||
await _app!.recordWatch(
|
||||
widget.title,
|
||||
episodeId: episode?.id,
|
||||
episodeTitle: episode?.title ?? '',
|
||||
sourceUrl: source.url,
|
||||
positionSeconds: result.position.inMilliseconds / 1000,
|
||||
durationSeconds: result.duration.inMilliseconds / 1000,
|
||||
);
|
||||
}
|
||||
if (!_current(generation)) return;
|
||||
if (!result.opened) {
|
||||
throw StateError('The native decoder did not open this source.');
|
||||
}
|
||||
final next =
|
||||
result.completed && prefs.autoNext && widget.localFile == null
|
||||
? nextEpisode(widget.playlist, episode?.id)
|
||||
: null;
|
||||
final nextSource = next == null
|
||||
? null
|
||||
: selectSource(next.sources, preferredQuality: source.quality);
|
||||
if (next != null && nextSource != null) {
|
||||
setState(() {
|
||||
_episode = next;
|
||||
_source = nextSource;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setState(() => _opening = false);
|
||||
if ((!result.completed || prefs.closeOnCompletion) && mounted) {
|
||||
await Navigator.maybePop(context);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
if (_current(generation)) {
|
||||
setState(() {
|
||||
_opening = false;
|
||||
_error = tr(
|
||||
context,
|
||||
'Could not play this source. Retry, choose another quality, or use an external player.',
|
||||
'پخش این منبع ممکن نشد. دوباره تلاش کنید، کیفیت دیگری انتخاب کنید یا از پخشکننده خارجی استفاده کنید.',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _external() async {
|
||||
try {
|
||||
if (widget.localFile != null &&
|
||||
RegExp(
|
||||
r'\.(m3u8|mpd)$',
|
||||
caseSensitive: false,
|
||||
).hasMatch(widget.localFile!)) {
|
||||
throw UnsupportedError('Use the in-app player for an offline package.');
|
||||
}
|
||||
await MediaPlatform.openExternal(
|
||||
widget.localFile ?? _source.url,
|
||||
packageName: _app!.prefs.externalPlayerPackage,
|
||||
title: widget.title.title,
|
||||
position: _resume(_episode),
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
context,
|
||||
'No external player could open this source.',
|
||||
'پخشکننده خارجی نتوانست این منبع را باز کند.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_generation++;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(_episode?.title ?? widget.title.title)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
if (_opening) const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 24),
|
||||
if (_error != null)
|
||||
Text(
|
||||
tr(context, 'Unable to play this source', 'پخش این منبع ممکن نیست'),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
_error ??
|
||||
tr(
|
||||
context,
|
||||
_opening
|
||||
? 'Opening the embedded VLC player'
|
||||
: 'Playback finished',
|
||||
_opening
|
||||
? 'در حال باز کردن پخشکننده داخلی VLC'
|
||||
: 'پخش پایان یافت',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _opening ? null : _open,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(
|
||||
tr(
|
||||
context,
|
||||
_error == null ? 'Play again' : 'Retry',
|
||||
_error == null ? 'پخش دوباره' : 'تلاش دوباره',
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _opening ? null : _external,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(tr(context, 'External player', 'پخشکننده خارجی')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!_opening && widget.localFile == null)
|
||||
for (final source in (_episode?.sources ?? widget.alternatives).where(
|
||||
(source) =>
|
||||
source.url != _source.url &&
|
||||
!source.isTrailer &&
|
||||
isRemoteMedia(source.url) &&
|
||||
ContentPolicy.allowsUrl(source.url),
|
||||
))
|
||||
ListTile(
|
||||
title: Text(
|
||||
source.quality.isEmpty
|
||||
? tr(context, 'Another source', 'منبع دیگر')
|
||||
: source.quality,
|
||||
),
|
||||
trailing: const Icon(Icons.play_arrow),
|
||||
onTap: () {
|
||||
_source = source;
|
||||
unawaited(_open());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Exit checkpoint from the embedded Android LibVLC activity. `opened` means
|
||||
/// the decoder actually reached Playing, not merely that Android opened a view.
|
||||
class NativePlaybackResult {
|
||||
const NativePlaybackResult({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.opened,
|
||||
required this.completed,
|
||||
});
|
||||
|
||||
final Duration position, duration;
|
||||
final bool opened, completed;
|
||||
|
||||
factory NativePlaybackResult.fromMap(Map<dynamic, dynamic> value) {
|
||||
int milliseconds(String key) {
|
||||
final raw = value[key];
|
||||
// Duration stores microseconds in a signed 64-bit integer on Android.
|
||||
if (raw is! int ||
|
||||
raw < 0 ||
|
||||
raw > 0x7fffffffffffffff ~/ Duration.microsecondsPerMillisecond) {
|
||||
throw FormatException('Invalid native playback $key');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
final position = milliseconds('positionMs');
|
||||
final duration = milliseconds('durationMs');
|
||||
if (value['opened'] is! bool || value['completed'] is! bool) {
|
||||
throw const FormatException('Invalid native playback flags');
|
||||
}
|
||||
// An EOF flag alone must not advance the playlist. Validate the raw timing
|
||||
// before its display bounds are applied, including unknown-duration input.
|
||||
final atEnd =
|
||||
duration > 0 &&
|
||||
position > 0 &&
|
||||
position <= duration &&
|
||||
duration - position <= (duration ~/ 50).clamp(1, 2000);
|
||||
return NativePlaybackResult(
|
||||
position: Duration(
|
||||
milliseconds: duration > 0 && position > duration ? duration : position,
|
||||
),
|
||||
duration: Duration(milliseconds: duration),
|
||||
opened: value['opened'] as bool,
|
||||
completed: value['completed'] == true && value['opened'] == true && atEnd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone native player, independent of the Flutter PlayerPage/decoder.
|
||||
///
|
||||
/// The future resolves ONLY when the activity exits. Keep any offline loopback
|
||||
/// server lease alive until then; source URLs are forwarded byte-for-byte.
|
||||
/// Callers own history persistence, ID association and local lease cleanup.
|
||||
/// Validation/platform failures may throw. If Android cannot launch the activity,
|
||||
/// the result has `opened: false`; no path fabricates successful playback.
|
||||
class NativePlayerBridge {
|
||||
static const _channel = MethodChannel('org.reelbox/media');
|
||||
|
||||
static bool get supported =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
static Future<NativePlaybackResult> open({
|
||||
required String url,
|
||||
required String title,
|
||||
Duration position = Duration.zero,
|
||||
bool autoplay = true,
|
||||
String externalPackage = 'ask',
|
||||
String orientation = 'auto',
|
||||
String videoFit = 'contain',
|
||||
double speed = 1.0,
|
||||
bool subtitleEnabled = true,
|
||||
String? subtitleUrl,
|
||||
bool hardwareAcceleration = true,
|
||||
bool pipEnabled = true,
|
||||
}) async {
|
||||
if (!supported) {
|
||||
throw UnsupportedError('The embedded native player requires Android.');
|
||||
}
|
||||
if (url.trim().isEmpty) throw ArgumentError.value(url, 'url');
|
||||
if (!speed.isFinite || speed < .25 || speed > 3) {
|
||||
throw ArgumentError.value(speed, 'speed', 'Must be between 0.25 and 3');
|
||||
}
|
||||
if (!const ['auto', 'portrait', 'landscape'].contains(orientation)) {
|
||||
throw ArgumentError.value(orientation, 'orientation');
|
||||
}
|
||||
if (!const ['contain', 'cover', 'fill'].contains(videoFit)) {
|
||||
throw ArgumentError.value(videoFit, 'videoFit');
|
||||
}
|
||||
final response = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'openNativePlayer',
|
||||
{
|
||||
'url': url,
|
||||
'title': title,
|
||||
'positionMs': position.inMilliseconds < 0 ? 0 : position.inMilliseconds,
|
||||
'autoplay': autoplay,
|
||||
'externalPackage': externalPackage,
|
||||
'orientation': orientation,
|
||||
'videoFit': videoFit,
|
||||
'speed': speed,
|
||||
'subtitleEnabled': subtitleEnabled,
|
||||
'subtitleUrl': ?subtitleUrl,
|
||||
'hardwareAcceleration': hardwareAcceleration,
|
||||
'pipEnabled': pipEnabled,
|
||||
},
|
||||
);
|
||||
if (response == null) {
|
||||
throw const FormatException('Native player returned no exit checkpoint');
|
||||
}
|
||||
return NativePlaybackResult.fromMap(response);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,174 @@
|
||||
/// Converts already-decoded SRT, SSA/ASS or WebVTT text to plain-cue WebVTT.
|
||||
/// Deliberately drops styling, attachments, identifiers and positioning rather
|
||||
/// than allowing receiver-side fetches or pretending to preserve ASS rendering.
|
||||
class SubtitleWebVtt {
|
||||
static String convert(String source) {
|
||||
final text = source
|
||||
.replaceFirst(RegExp(r'^\uFEFF'), '')
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.trim();
|
||||
final cues = <_Cue>[];
|
||||
if (RegExp(r'^\[Script Info\]', caseSensitive: false).hasMatch(text)) {
|
||||
_ass(text, cues);
|
||||
} else {
|
||||
final blocks = text.split(RegExp(r'\n[ \t]*\n'));
|
||||
final vtt = RegExp(r'^WEBVTT(?:[ \t]|$)')
|
||||
.hasMatch(blocks.first.split('\n').first);
|
||||
if (vtt) blocks.removeAt(0); // Header metadata is never propagated.
|
||||
for (final block in blocks) {
|
||||
final lines = block.trim().split('\n');
|
||||
if (vtt &&
|
||||
RegExp(r'^(?:STYLE|REGION|NOTE)(?:[ \t]|$)')
|
||||
.hasMatch(lines.first)) {
|
||||
continue;
|
||||
}
|
||||
if (lines.length > 1 && !lines.first.contains('-->')) {
|
||||
if (!vtt && !RegExp(r'^\d+$').hasMatch(lines.first)) {
|
||||
throw const FormatException(
|
||||
'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.',
|
||||
);
|
||||
}
|
||||
lines.removeAt(0);
|
||||
}
|
||||
if (lines.length < 2) {
|
||||
throw const FormatException('Invalid or empty subtitle cue.');
|
||||
}
|
||||
final timing = RegExp(r'^(\S+)[ \t]+-->[ \t]+(\S+)(?:[ \t]+.*)?$')
|
||||
.firstMatch(lines.first);
|
||||
if (timing == null) {
|
||||
throw const FormatException(
|
||||
'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.',
|
||||
);
|
||||
}
|
||||
cues.add(
|
||||
_Cue(
|
||||
_time(timing[1]!, short: vtt),
|
||||
_time(timing[2]!, short: vtt),
|
||||
_plain(lines.skip(1).join('\n')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cues.isEmpty) {
|
||||
throw const FormatException('No supported subtitle cues.');
|
||||
}
|
||||
var previous = -1;
|
||||
for (final cue in cues) {
|
||||
if (cue.end <= cue.start || cue.start < previous || cue.body.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Invalid subtitle cue timing/order or empty text.',
|
||||
);
|
||||
}
|
||||
previous = cue.start;
|
||||
}
|
||||
return 'WEBVTT\n\n${cues.map((c) => '${_stamp(c.start)} --> ${_stamp(c.end)}\n${c.body}').join('\n\n')}\n\n';
|
||||
}
|
||||
|
||||
static int _time(String value, {bool short = false, bool ass = false}) {
|
||||
final expression = ass
|
||||
? r'^(\d{1,6}):(\d{2}):(\d{2})\.(\d{2})$'
|
||||
: r'^(\d{2,6}):(\d{2}):(\d{2})[.,](\d{3})$';
|
||||
var match = RegExp(expression).firstMatch(value);
|
||||
if (match == null && short) {
|
||||
match = RegExp(r'^(\d{2}):(\d{2})\.(\d{3})$').firstMatch(value);
|
||||
if (match != null) {
|
||||
return _time('00:$value');
|
||||
}
|
||||
}
|
||||
if (match == null) {
|
||||
throw const FormatException('Invalid subtitle timestamp.');
|
||||
}
|
||||
final h = int.parse(match[1]!);
|
||||
final m = int.parse(match[2]!);
|
||||
final s = int.parse(match[3]!);
|
||||
if (m > 59 || s > 59) {
|
||||
throw const FormatException('Invalid subtitle timestamp range.');
|
||||
}
|
||||
return ((h * 60 + m) * 60 + s) * 1000 +
|
||||
int.parse(match[4]!) * (ass ? 10 : 1);
|
||||
}
|
||||
|
||||
static String _stamp(int ms) {
|
||||
String pad(int value, int width) => '$value'.padLeft(width, '0');
|
||||
return '${pad(ms ~/ 3600000, 2)}:${pad(ms ~/ 60000 % 60, 2)}:${pad(ms ~/ 1000 % 60, 2)}.${pad(ms % 1000, 3)}';
|
||||
}
|
||||
|
||||
static void _ass(String text, List<_Cue> cues) {
|
||||
var events = false;
|
||||
List<String>? fields;
|
||||
for (final raw in text.split('\n')) {
|
||||
final line = raw.trim();
|
||||
if (line.startsWith('[')) {
|
||||
events = line.toLowerCase() == '[events]';
|
||||
continue;
|
||||
}
|
||||
if (!events) continue;
|
||||
if (line.toLowerCase().startsWith('format:')) {
|
||||
fields = line
|
||||
.substring(line.indexOf(':') + 1)
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.toList();
|
||||
} else if (line.toLowerCase().startsWith('dialogue:')) {
|
||||
if (fields == null ||
|
||||
fields.last != 'text' ||
|
||||
!fields.contains('start') ||
|
||||
!fields.contains('end') ||
|
||||
fields.toSet().length != fields.length) {
|
||||
throw const FormatException('Invalid SSA/ASS Events format.');
|
||||
}
|
||||
final parts = line.substring(line.indexOf(':') + 1).split(',');
|
||||
if (parts.length < fields.length) {
|
||||
throw const FormatException('Incomplete SSA/ASS dialogue.');
|
||||
}
|
||||
final body = parts.skip(fields.length - 1).join(',');
|
||||
if (RegExp(r'\\p[1-9]').hasMatch(body)) {
|
||||
throw UnsupportedError('ASS drawing subtitles are not supported.');
|
||||
}
|
||||
cues.add(
|
||||
_Cue(
|
||||
_time(parts[fields.indexOf('start')].trim(), ass: true),
|
||||
_time(parts[fields.indexOf('end')].trim(), ass: true),
|
||||
_plain(
|
||||
body
|
||||
.replaceAll(r'\N', '\n')
|
||||
.replaceAll(r'\n', '\n')
|
||||
.replaceAll(r'\h', ' '),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _plain(String body) {
|
||||
// Plain escaped text is a strict subset of WebVTT: no HTML resources,
|
||||
// CSS, voice/class attributes, ASS overrides, or embedded timing tags.
|
||||
return body
|
||||
.replaceAll(
|
||||
RegExp(
|
||||
r'<(script|style)\b[^>]*>.*?</\1\s*>',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.replaceAll(RegExp(r'\{[^}]*\}'), '')
|
||||
.replaceAll(RegExp(r'<[^>]*>', dotAll: true), '')
|
||||
.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
class _Cue {
|
||||
const _Cue(this.start, this.end, this.body);
|
||||
final int start, end;
|
||||
final String body;
|
||||
}
|
||||
Reference in new issue
Block a user