[verified] Publish ReelBox source under GPLv3

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

No files matched your search

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