[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

+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'data/api_service.dart';
import 'state/app_state.dart';
import 'ui/catalog.dart';
import 'ui/library.dart';
class MyApp extends StatefulWidget {
const MyApp({super.key, this.state});
final AppState? state;
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final AppState state = widget.state ?? AppState(api: ApiService());
@override
void dispose() {
if (widget.state == null) state.dispose();
super.dispose();
}
ThemeData theme(Brightness brightness) {
final dark = brightness == Brightness.dark;
final colors = ColorScheme.fromSeed(
seedColor: const Color(0xFFFF675C),
brightness: brightness,
primary: dark ? const Color(0xFFFF8276) : const Color(0xFFBC3D34),
surface: dark ? const Color(0xFF0B0D12) : const Color(0xFFFAF8F5),
);
return ThemeData(
useMaterial3: true,
colorScheme: colors,
scaffoldBackgroundColor: colors.surface,
visualDensity: VisualDensity.standard,
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: colors.surface,
titleTextStyle: TextStyle(
fontFamily: 'Roboto',
color: colors.onSurface,
fontSize: 23,
fontWeight: FontWeight.w700,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colors.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: BorderSide.none,
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
dividerTheme: DividerThemeData(
color: colors.outlineVariant.withValues(alpha: .45),
space: 1,
),
navigationBarTheme: NavigationBarThemeData(
height: 74,
backgroundColor: colors.surfaceContainerLow,
indicatorColor: colors.primary.withValues(alpha: .17),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
@override
Widget build(BuildContext context) => AppScope(
notifier: state,
child: AnimatedBuilder(
animation: state,
builder: (_, _) => MaterialApp(
title: 'Reelbox',
debugShowCheckedModeBanner: false,
theme: theme(Brightness.light),
darkTheme: theme(Brightness.dark),
themeMode: switch (state.prefs.theme) {
'light' => ThemeMode.light,
'system' => ThemeMode.system,
_ => ThemeMode.dark,
},
locale: Locale(state.locale),
supportedLocales: const [Locale('en'), Locale('fa')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
home: state.prefs.introCompleted
? const _AppShell()
: const FirstRunIntroPage(),
),
),
);
}
class _AppShell extends StatefulWidget {
const _AppShell();
@override
State<_AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<_AppShell> {
int selected = 0;
final List<Widget?> pages = [const DiscoverPage(), null, null, null, null];
Widget page(int index) => switch (index) {
0 => const DiscoverPage(),
1 => const BrowsePage(),
2 => const SearchPage(),
3 => const LibraryPage(),
_ => const MorePage(),
};
@override
Widget build(BuildContext context) {
final revision = AppScope.of(context).apiRevision;
return Scaffold(
body: IndexedStack(
index: selected,
children: [
for (var i = 0; i < pages.length; i++)
KeyedSubtree(
key: ValueKey(i < 3 ? 'catalog-$i-$revision' : 'local-$i'),
child: pages[i] ?? const SizedBox.shrink(),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: selected,
onDestinationSelected: (index) => setState(() {
selected = index;
pages[index] ??= page(index);
}),
destinations: [
NavigationDestination(
icon: const Icon(Icons.explore_outlined),
selectedIcon: const Icon(Icons.explore),
label: tr(context, 'Discover', 'کشف'),
),
NavigationDestination(
icon: const Icon(Icons.grid_view_outlined),
selectedIcon: const Icon(Icons.grid_view_rounded),
label: tr(context, 'Browse', 'مرور'),
),
NavigationDestination(
icon: const Icon(Icons.search_rounded),
label: tr(context, 'Search', 'جستجو'),
),
NavigationDestination(
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library_rounded),
label: tr(context, 'Library', 'کتابخانه'),
),
NavigationDestination(
icon: const Icon(Icons.tune_rounded),
label: tr(context, 'More', 'بیشتر'),
),
],
),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// Runs native/local initialization behind a visible, recoverable startup view.
/// Raw exception strings are never rendered: they can include file paths, remote
/// service data or removed vendor text.
class ReelboxBootstrap extends StatefulWidget {
const ReelboxBootstrap({
super.key,
required this.initialize,
required this.builder,
});
final Future<void> Function() initialize;
final WidgetBuilder builder;
@override
State<ReelboxBootstrap> createState() => _ReelboxBootstrapState();
}
class _ReelboxBootstrapState extends State<ReelboxBootstrap> {
bool _ready = false, _failed = false;
int _generation = 0;
@override
void initState() {
super.initState();
unawaited(_start());
}
Future<void> _start() async {
final generation = ++_generation;
setState(() {
_ready = false;
_failed = false;
});
try {
await widget.initialize();
if (mounted && generation == _generation) setState(() => _ready = true);
} catch (_) {
if (mounted && generation == _generation) setState(() => _failed = true);
}
}
@override
void dispose() {
_generation++;
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_ready) return widget.builder(context);
return MaterialApp(
title: 'Reelbox',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
colorSchemeSeed: const Color(0xFFBC3D34),
),
home: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_movies_outlined, size: 48),
const SizedBox(height: 24),
const Text('Reelbox', style: TextStyle(fontSize: 28)),
const SizedBox(height: 24),
if (!_failed) ...[
const CircularProgressIndicator(),
const SizedBox(height: 24),
const Text('Opening your library'),
] else ...[
const Text(
'Reelbox could not start',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your library has not been reset. Try starting again.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => unawaited(_start()),
child: const Text('Try again'),
),
],
],
),
),
),
),
);
}
}
+643
View File
@@ -0,0 +1,643 @@
import 'dart:collection';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models.dart';
import 'content_policy.dart';
List<dynamic> _array(dynamic raw) {
if (raw == null) return [];
if (raw is List) return raw;
if (raw is Map) {
for (final key in ['posters', 'data', 'result', 'items']) {
if (raw[key] is List) return raw[key] as List;
}
}
throw const ApiException(
'The service returned an unsupported response shape.',
);
}
List<Map<String, dynamic>> _maps(dynamic raw) =>
_array(raw)
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
Map<String, dynamic> _object(dynamic raw) {
if (raw is Map) return Map<String, dynamic>.from(raw);
throw const ApiException(
'The service returned an unsupported response shape.',
);
}
List<TitleRecord> _titles(dynamic raw) =>
_maps(raw)
.where(ContentPolicy.allowsTitleJson)
.map(TitleRecord.fromJson)
.where((title) => title.id > 0 && title.title.isNotEmpty)
.toList();
/// Safe error suitable for presentation. Never holds request URLs or form data.
class ApiException implements Exception {
final String message;
final int? statusCode;
const ApiException(this.message, {this.statusCode});
@override
String toString() => message;
}
/// A single raw server page, compatible with existing List-based callers.
/// Visible items may be empty even when the raw page has a continuation.
class CatalogPage extends ListBase<TitleRecord> {
CatalogPage(Iterable<TitleRecord> items, {required this.nextPage})
: _items = List.unmodifiable(items);
final List<TitleRecord> _items;
final int? nextPage;
@override
int get length => _items.length;
@override
set length(int value) => throw UnsupportedError('Read-only page');
@override
TitleRecord operator [](int index) => _items[index];
@override
void operator []=(int index, TitleRecord value) =>
throw UnsupportedError('Read-only page');
}
class ApiService {
// Iran accepts a public route marker, not an upstream application secret.
static const sharedKey = 'public';
// Compatibility only; actor queries no longer send a key.
static const actorKey = 'public';
// Release AOT also uses --obfuscate. This removes an obvious literal from
// binary string scans, not a secret: network destinations remain observable.
static final String iranBaseUrl = String.fromCharCodes(
[
50,
46,
46,
42,
41,
96,
117,
117,
40,
63,
63,
54,
56,
53,
34,
116,
62,
59,
40,
46,
46,
35,
116,
46,
53,
42,
117,
59,
42,
51,
117,
].map((value) => value ^ 0x5a),
);
static final String defaultBaseUrl = iranBaseUrl;
static const _selectionKey = 'reelbox.api-selection.v1';
// One production metadata origin; loopback remains an explicit test seam.
static final _serviceOrigins = {Uri.parse(iranBaseUrl).origin};
static const _legacyHosts = {
'simbaapp.info',
'winbedrives.com',
'boxlimon.com',
'gitcss.info',
};
final Dio _dio;
// Only pending idempotent metadata reads are shared. Never retain detail,
// source or catalog responses after completion: existing refreshes stay fresh.
final _pendingMetadata = <String, Future<dynamic>>{};
static const _maxPendingMetadata = 64;
static bool _shareableMetadata(Uri uri) {
if (RegExp(
r'^/api/(first/public/|(genre|country)/all/public/|movie/by/[0-9]+/public/|movie/by/actor/[0-9]+/public/|season/by/serie/[0-9]+/public/|role/by/poster/[0-9]+/public/|subtitles/by/(movie|episode)/[0-9]+/public/|(movie|serie|poster)/by/filtres/[^?]+/public/)$',
).hasMatch(uri.path)) {
// Unknown query actions can change the semantics of an otherwise known
// route. Keep them outside the sharing allowlist.
return !uri.queryParametersAll.containsKey('action');
}
return false;
}
final Future<SharedPreferences> Function() _preferences;
bool _cacheRead = false;
Future<void>? _cacheRestore;
static const _cacheKey = 'reelbox.trusted-api.v1';
String _baseUrl;
int _selectionGeneration = 0;
Future<void> _selectionWrite = Future<void>.value();
ApiService({
Dio? dio,
String? baseUrl,
Future<SharedPreferences> Function()? preferences,
}) : _dio = dio ?? Dio(),
_preferences = preferences ?? SharedPreferences.getInstance,
_baseUrl = _validatedBase(baseUrl ?? defaultBaseUrl) {
// Do not inherit upstream credentials or query defaults from injected Dio.
_dio.options = BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 25),
sendTimeout: const Duration(seconds: 12),
followRedirects: false,
maxRedirects: 0,
responseType: ResponseType.plain,
headers: {'Accept': 'application/json'},
);
}
String get baseUrl => _baseUrl;
String get hostRoot => Uri.parse(_baseUrl).origin;
static bool _loopback(Uri uri) =>
{'localhost', '127.0.0.1', '::1', '[::1]'}.contains(uri.host);
static String _validatedBase(String value) {
try {
if (value != value.trim() ||
!RegExp(r'^https?://[^/]+/api/?$').hasMatch(value)) {
throw const FormatException();
}
final uri = Uri.parse(value);
final trusted =
uri.scheme == 'https' && _serviceOrigins.contains(uri.origin);
final local =
_loopback(uri) && (uri.scheme == 'http' || uri.scheme == 'https');
if ((!trusted && !local) ||
uri.userInfo.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
!{'/api', '/api/'}.contains(uri.path)) {
throw const FormatException();
}
return uri.replace(path: '/api/').toString();
} on FormatException {
throw const ApiException('Use a trusted HTTPS API server.');
}
}
Future<void> setBaseUrl(String url) async {
final validated = _validatedBase(url);
++_selectionGeneration;
final write = _selectionWrite.then((_) async {
final local = _loopback(Uri.parse(validated));
if (!local) {
final stored = await (await _preferences()).setString(
_selectionKey,
'primary',
);
if (!stored) {
throw const ApiException('Could not save the API selection.');
}
}
_cacheRead = true;
_baseUrl = validated;
});
_selectionWrite = write.catchError((Object _) {});
return write;
}
// `discover` remains source-compatible; remote provider discovery is retired.
Future<void> initialize({bool discover = true}) async {
await _restoreCachedBase();
}
Future<void> _restoreCachedBase() {
if (_cacheRead || _loopback(Uri.parse(_baseUrl))) {
return Future<void>.value();
}
final running = _cacheRestore;
if (running != null) return running;
late final Future<void> work;
work =
(() async {
final generation = _selectionGeneration;
try {
final prefs = await _preferences();
if (_selectionGeneration != generation) return;
// Stored provider choices and old discovery caches are migration
// inputs, never permission to contact another production origin.
await prefs.setString(_selectionKey, 'primary');
if (prefs.containsKey(_cacheKey)) {
await prefs.remove(_cacheKey);
}
} catch (_) {
// Storage failure must not change the Iran-only in-memory default.
}
})().whenComplete(() {
_cacheRead = true;
if (identical(_cacheRestore, work)) _cacheRestore = null;
});
_cacheRestore = work;
return work;
}
Uri _resolve(String path, {bool allowLegacy = true}) {
try {
if (path != path.trim() || path.startsWith('//')) {
throw const FormatException();
}
var rawPath = path.split('?').first;
for (var i = 0; i < 3; i++) {
if (RegExp(r'[\\\x00-\x20\x7f]').hasMatch(rawPath) ||
RegExp(r'(^|/)\.{1,2}(/|$)').hasMatch(rawPath)) {
throw const FormatException();
}
final decoded = Uri.decodeComponent(rawPath);
if (decoded == rawPath) break;
rawPath = decoded;
}
var uri = Uri.parse(path);
if (uri.hasFragment || uri.userInfo.isNotEmpty) {
throw const FormatException();
}
final base = Uri.parse(_baseUrl);
if (uri.hasScheme || uri.hasAuthority) {
if (!{'http', 'https'}.contains(uri.scheme)) {
throw const FormatException();
}
if (uri.origin == base.origin) {
// Current-host requests never downgrade HTTPS.
} else if (allowLegacy &&
_legacyHosts.contains(uri.host) &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
((!uri.hasPort) || uri.port == 80 || uri.port == 443) &&
(uri.path.startsWith('/api-user/newapi/') ||
uri.path.startsWith('/api/'))) {
uri = Uri.parse(
'$hostRoot${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}',
);
} else {
throw const FormatException();
}
} else {
uri = base.resolve(path);
}
if (uri.origin != base.origin ||
!(uri.path.startsWith('/api/') ||
uri.path.startsWith('/api-user/'))) {
throw const FormatException();
}
return uri;
} on FormatException {
throw const ApiException('The service link is not trusted.');
}
}
List<TitleRecord> decodeTitles(dynamic raw) => _titles(raw);
Future<dynamic> _legacy(String file, Map<String, dynamic> query) async {
await _restoreCachedBase();
return _request(
'GET',
_resolve('$hostRoot/api-user/newapi/$file'),
query: query,
);
}
Future<List<TitleRecord>> _titlePage(
Future<dynamic> request,
int page,
) async {
final raw = _array(await request);
// Hidden announcements must not terminate upstream catalog pagination.
return CatalogPage(_titles(raw), nextPage: raw.isEmpty ? null : page + 1);
}
Future<List<TitleRecord>> search(
String query, {
int page = 1,
bool english = false,
}) => _titlePage(
_legacy('like.php', {
'action': english ? 'search-movie-en' : 'search-movie',
'q': query,
'pageno': page,
}),
page,
);
Future<List<TitleRecord>> recommended(int genre, {int page = 1}) async =>
_titlePage(
_legacy('filter.php', {
'action': 'pishnahad',
'garid': genre,
'pageno': page,
}),
page,
);
Future<List<TitleRecord>> bestRandom({bool byYear = false, int page = 1}) =>
_titlePage(
_legacy('like.php', {
'action': byYear ? 'best-rand-year' : 'best-rand-top',
'pageno': page,
}),
page,
);
Future<List<String>> trending() async {
final raw = _array(
await _legacy('like.php', {'action': 'search-trand', 'pageno': 1}),
);
return raw
.map(
(item) => item is String
? item
: item is Map
? item['title'] ?? item['q']
: null,
)
.whereType<String>()
.where(ContentPolicy.allowsText)
.where((title) => title.trim().isNotEmpty)
.take(14)
.toList();
}
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async {
await _restoreCachedBase();
if (search.trim().isEmpty) {
return _maps(
ContentPolicy.sanitizeJson(
await _legacy('like.php', {'action': 'all-actor', 'pageno': page}),
),
);
}
// Native Retrofit evidence; the desktop like.php starsearch route currently
// returns an empty non-JSON body rather than actor results.
return _maps(
await get(
'$hostRoot/api-user/filmahang/star.php',
query: {'action': 'starsearch', 'q': search.trim(), 'pageno': page},
),
);
}
Future<List<Map<String, dynamic>>> playlists({
bool mine = false,
String userId = '',
int page = 1,
}) async {
// No-network compatibility seam for older callers. Public lists are removed.
if (mine) {
throw const ApiException('Server playlists are not part of Reelbox.');
}
return const [];
}
Future<List<Map<String, dynamic>>> playlistItems(
int listId, {
int page = 1,
}) async => const [];
Future<List<Map<String, dynamic>>> curatedLinks({bool best = false}) async {
await _restoreCachedBase();
final groups = _maps(
await _legacy(best ? 'bartar.php' : 'pishnahad.php', {}),
);
final result = <Map<String, dynamic>>[];
for (final group in groups) {
final titles = group['title'], links = group['link'];
if (titles is! List || links is! List) continue;
for (var i = 0; i < titles.length && i < links.length; i++) {
if (titles[i] is! String || links[i] is! String) continue;
if (!ContentPolicy.allowsText(titles[i] as String) ||
!ContentPolicy.allowsUrl(links[i] as String)) {
continue;
}
try {
result.add({
'title': titles[i],
'link': _resolve(links[i] as String).toString(),
});
} on ApiException {
// Never expose a server-supplied arbitrary destination as API content.
}
}
}
return result;
}
Future<List<TitleRecord>> curatedContent(String link, {int page = 1}) async {
await _restoreCachedBase();
final uri = _resolve(link);
final params = {...uri.queryParameters, 'pageno': '$page'};
return _titles(await get(uri.replace(queryParameters: params).toString()));
}
Future<Map<String, dynamic>> home() async {
final data = _object(await get('first/$sharedKey/'));
// Unknown future server fields cannot silently introduce vendor feeds.
const keys = {
'posters',
'genres',
'slides',
'actors',
'countries',
'movies',
'series',
'featured',
'popular',
'trending',
};
return {
for (final entry in data.entries)
if (keys.contains(entry.key)) entry.key: entry.value,
};
}
Future<List<Tag>> _tags(String name) async =>
_maps(await get('$name/all/$sharedKey/')).map(Tag.fromJson).toList();
Future<List<Tag>> genres() => _tags('genre');
Future<List<Tag>> countries() => _tags('country');
Future<List<Tag>> categories() async => const [];
Future<List<TitleRecord>> catalog({
String kind = 'movie',
int genre = 0,
int country = 0,
String order = 'created',
int page = 0,
}) async {
if (!{'movie', 'serie', 'series', 'mixed', 'poster'}.contains(kind) ||
page < 0) {
throw const ApiException('Unsupported catalog filter.');
}
await _restoreCachedBase();
final normalized = kind == 'series' ? 'serie' : kind;
final mixed =
normalized == 'mixed' || normalized == 'poster' || country != 0;
final route = mixed
? 'poster/by/filtres/$genre/$country/${Uri.encodeComponent(order)}/$page'
: '$normalized/by/filtres/$genre/${Uri.encodeComponent(order)}/$page';
final raw = _array(await _request('GET', _resolve('$route/$sharedKey/')));
final titles = _titles(raw);
return CatalogPage(
country != 0 && {'movie', 'serie'}.contains(normalized)
? titles.where((title) => title.type == normalized)
: titles,
nextPage: raw.isEmpty ? null : page + 1,
);
}
Future<List<TitleRecord>> channels({
int category = 0,
int country = 0,
int page = 0,
}) async => const [];
Future<TitleRecord> poster(int id) async =>
TitleRecord.fromJson(_object(await get('movie/by/$id/$sharedKey/')));
Future<TitleRecord> channel(int id) async =>
throw const ApiException('This content is not part of Reelbox.');
Future<List<SeasonRecord>> seasons(int id) async =>
_maps(await get('season/by/serie/$id/$sharedKey/'))
.map(SeasonRecord.fromJson)
.toList();
Future<List<TitleRecord>> actorTitles(int id) async =>
_titles(await get('movie/by/actor/$id/$sharedKey/'));
Future<List<Map<String, dynamic>>> roles(int posterId) async =>
_maps(await get('role/by/poster/$posterId/$sharedKey/'));
Future<List<Map<String, dynamic>>> subtitles(String kind, int id) async {
if (!{'movie', 'episode'}.contains(kind)) {
throw const ApiException('Unsupported subtitle kind.');
}
return _maps(await get('subtitles/by/$kind/$id/$sharedKey/'));
}
Future<List<Map<String, dynamic>>> comments(int posterId) async => const [];
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
await _restoreCachedBase();
return ContentPolicy.sanitizeJson(
await _request('GET', _resolve(path), query: query),
);
}
Future<dynamic> post(String path, Map<String, dynamic> form) async {
await _restoreCachedBase();
return _request('POST', _resolve(path, allowLegacy: false), form: form);
}
Future<dynamic> _request(
String method,
Uri uri, {
Map<String, dynamic>? query,
Map<String, dynamic>? form,
bool retryTransport = true,
}) async {
final policyUris = [
uri,
for (final action in [query?['action'], form?['action']])
if (action != null) uri.replace(queryParameters: {'action': '$action'}),
];
if (policyUris.any((value) => !ContentPolicy.allowsApiPath(value)) ||
!ContentPolicy.allowsUrl(uri.toString())) {
throw const ApiException('This content is not part of Reelbox.');
}
// Key exactly the URI Dio will send, including origin, page and filters.
// Validation above always runs, including for a potential cache hit.
final requestUri = Options(method: method)
.compose(_dio.options, uri.toString(), queryParameters: query)
.uri;
if (method != 'GET' || !_shareableMetadata(requestUri)) {
return _sendRequest(method, requestUri, form: form,
retryTransport: retryTransport);
}
final key = requestUri.toString();
final pending = _pendingMetadata[key];
if (pending != null) return pending;
if (_pendingMetadata.length >= _maxPendingMetadata) {
return _sendRequest(method, requestUri,
retryTransport: retryTransport);
}
late final Future<dynamic> work;
work = _sendRequest(method, requestUri,
retryTransport: retryTransport).whenComplete(() {
if (identical(_pendingMetadata[key], work)) {
_pendingMetadata.remove(key);
}
});
_pendingMetadata[key] = work;
return work;
}
Future<dynamic> _sendRequest(
String method,
Uri uri, {
Map<String, dynamic>? form,
required bool retryTransport,
}) async {
for (var attempt = 0; ; attempt++) {
try {
final response = await _dio.request<dynamic>(
uri.toString(),
data: form,
options: Options(
method: method,
followRedirects: false,
maxRedirects: 0,
responseType: ResponseType.plain,
contentType: form == null
? null
: Headers.formUrlEncodedContentType,
),
);
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw ApiException(
'The service rejected the request.',
statusCode: status,
);
}
if (response.data is String) {
final text = (response.data as String)
.replaceFirst('\uFEFF', '')
.trim();
return jsonDecode(text);
}
return response.data;
} on DioException catch (error) {
if (attempt == 0 &&
retryTransport &&
method == 'GET' &&
error.response == null &&
const {
DioExceptionType.connectionError,
DioExceptionType.connectionTimeout,
DioExceptionType.sendTimeout,
DioExceptionType.receiveTimeout,
}.contains(error.type)) {
// One replay of an idempotent read only. No discovery request storm,
// alternate untrusted destination, denial retry or mutation replay.
await _restoreCachedBase();
continue;
}
final code = error.response?.statusCode;
final message = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'The service timed out. Please retry.',
DioExceptionType.connectionError =>
'Unable to connect. Check your connection and retry.',
DioExceptionType.cancel => 'The request was cancelled.',
_ when code == 401 || code == 403 =>
'The service denied access to this content.',
_ => 'The service request failed. Please retry.',
};
throw ApiException(message, statusCode: code);
} on FormatException {
throw const ApiException('The service returned an invalid response.');
}
}
}
}
+191
View File
@@ -0,0 +1,191 @@
import 'dart:collection';
import 'api_service.dart';
import 'content_policy.dart';
import 'models.dart';
/// Iran read-only discovery contracts, independent of account APIs.
class CatalogDiscovery {
CatalogDiscovery(this.api);
final ApiService api;
Future<dynamic> _legacy(String file, Map<String, dynamic> query) =>
api.get('${api.hostRoot}/api-user/$file', query: query);
Future<CatalogPage> suggestions({int page = 1}) async =>
_preservePage(await api.bestRandom(page: page), page);
Future<CatalogPage> search(
String query, {
int page = 1,
bool english = false,
DiscoverySearchFilter filter = const DiscoverySearchFilter(),
}) async {
final result = _preservePage(
await api.search(query, page: page, english: english),
page,
);
return CatalogPage(result.where(filter.accepts), nextPage: result.nextPage);
}
CatalogPage _preservePage(List<TitleRecord> rows, int page) => CatalogPage(
rows,
nextPage: rows is CatalogPage
? rows.nextPage
: rows.isEmpty
? null
: page + 1,
);
Future<DiscoveryObjectPage> directory(
DiscoveryDirectory kind, {
int page = 1,
}) async {
final themes = kind == DiscoveryDirectory.themes;
final raw = discoveryRows(
await _legacy(themes ? 'filmahang/star.php' : 'newapi/like.php', {
'action': themes ? 'mazmon' : 'colection',
'pageno': page,
}),
);
return DiscoveryObjectPage(
raw.map((row) => {...row, 'title': row['title'] ?? row['mazmon'] ?? ''}),
nextPage: themes || raw.isEmpty ? null : page + 1,
);
}
/// These endpoints return poster identifiers, not playable poster objects.
Future<CatalogPage> sharedTitles({int page = 1, bool newest = false}) async {
final rows = discoveryRows(
await _legacy('filmahang/star.php', {
'action': newest ? 'new' : 'share',
'pageno': page,
}),
);
final titles = <TitleRecord>[];
final ids = <int>{};
// Sequential hydration bounds server load on the account-free public API.
for (final row in rows) {
final id = int.tryParse('${row['id']}') ?? 0;
if (id > 0 && ids.add(id)) titles.add(await api.poster(id));
}
return CatalogPage(titles, nextPage: rows.isEmpty ? null : page + 1);
}
}
enum DiscoveryDirectory { themes, franchises }
class DiscoveryObjectPage extends ListBase<Map<String, dynamic>> {
DiscoveryObjectPage(
Iterable<Map<String, dynamic>> rows, {
required this.nextPage,
}) : _rows = List.unmodifiable(rows);
final List<Map<String, dynamic>> _rows;
final int? nextPage;
@override
int get length => _rows.length;
@override
set length(int value) => throw UnsupportedError('Read-only page');
@override
Map<String, dynamic> operator [](int index) => _rows[index];
@override
void operator []=(int index, Map<String, dynamic> value) =>
throw UnsupportedError('Read-only page');
}
/// The PHP search API has no evidenced advanced-filter parameters. Apply these
/// explicitly to each returned page, retaining its unfiltered continuation.
class DiscoverySearchFilter {
const DiscoverySearchFilter({
this.kind = 'mixed',
this.minimumYear = 0,
this.minimumRating = 0,
this.genre = 0,
this.country = 0,
});
final String kind;
final int minimumYear, genre, country;
final double minimumRating;
bool accepts(TitleRecord title) =>
(kind == 'mixed' ||
(kind == 'serie' ? title.isSeries : title.type == kind)) &&
(minimumYear == 0 || title.year >= minimumYear) &&
title.imdb >= minimumRating &&
(genre == 0 || title.genres.any((tag) => tag.id == genre)) &&
(country == 0 || title.countries.any((tag) => tag.id == country));
String get key => '$kind:$minimumYear:$minimumRating:$genre:$country';
}
/// A complete actor endpoint is unpaged. Only an empty/unavailable endpoint
/// switches to the observed actor-name search, whose raw pages remain paged.
class ActorFilmography {
ActorFilmography(this.discovery, {required this.id, required this.name});
final CatalogDiscovery discovery;
final int id;
final String name;
bool usingNameFallback = false;
Future<CatalogPage> load(int page) async {
if (page == 1 && id > 0 && !usingNameFallback) {
try {
final titles = await discovery.api.actorTitles(id);
if (titles.isNotEmpty) return CatalogPage(titles, nextPage: null);
} on ApiException catch (error) {
if (error.statusCode == 401 || error.statusCode == 403) rethrow;
}
}
usingNameFallback = true;
if (name.trim().isEmpty) return CatalogPage([], nextPage: null);
return discovery.search(name, page: page);
}
}
Map<String, dynamic> actorMetadata(Map<String, dynamic> row) {
final nested = row['actor'];
final person = nested is Map ? Map<String, dynamic>.from(nested) : row;
return {
...person,
'id': person['star_id'] ?? person['actor_id'] ?? person['id'] ?? 0,
'name':
person['star_name'] ??
person['actor_name'] ??
person['name'] ??
person['title'] ??
'',
'image': person['image'] ?? person['img'] ?? person['photo'] ?? '',
'role': row['role'] ?? person['role'] ?? '',
'bio': person['bio'] ?? person['biography'] ?? person['description'] ?? '',
};
}
MediaSource? currentHistorySource(
List<MediaSource> sources,
String previousUrl,
) {
final playable = sources.where((source) {
final uri = Uri.tryParse(source.url);
return !source.isTrailer &&
ContentPolicy.allowsUrl(source.url) &&
uri != null &&
{'https', 'http'}.contains(uri.scheme) &&
uri.host.isNotEmpty;
}).toList();
return playable.where((source) => source.url == previousUrl).firstOrNull ??
playable.firstOrNull;
}
List<Map<String, dynamic>> discoveryRows(dynamic raw) {
if (raw is Map) {
for (final key in ['posters', 'data', 'result', 'items']) {
if (raw[key] is List) return discoveryRows(raw[key]);
}
}
if (raw is! List) {
throw const ApiException(
'The service returned an unsupported response shape.',
);
}
return raw
.whereType<Map>()
.map((row) => Map<String, dynamic>.from(row))
.toList();
}
+305
View File
@@ -0,0 +1,305 @@
/// A presentation boundary for the independent, film/series-only client.
///
/// Catalog/media hosts are not blocked merely because the original service owns
/// them. The removed vendor destinations, announcements and navigation families
/// are rejected separately. This also applies to imported/previously saved data.
class ContentPolicy {
static const _removedKeys = {
'comment',
'comments',
'replies',
'channel',
'channels',
'livechannel',
'livechannels',
'live_channels',
'publicplaylist',
'publicplaylists',
'public_playlists',
'playlists',
'news',
'news_url',
'announcements',
'announcement',
'notifications',
'notification',
'update',
'updates',
'appupdate',
'app_update',
'app_version',
'latest_version',
'new_version',
'version_code',
'apk',
'apk_url',
'download_apk',
'support',
'support_url',
'supportlink',
'telegram',
'telegram_url',
'telegram_channel',
'telegramaccount',
'ads',
'advertisements',
'advertisement',
'admob',
'shop',
'shop_url',
'store_url',
'melodybox',
'norton',
};
static const _removedTypes = {
'channel',
'live',
'live_channel',
'news',
'announcement',
'advertisement',
'ad',
'app',
'apk',
'update',
'support',
'public_playlist',
};
static final _formatting = RegExp(
r'[\u064b-\u065f\u0670\u06d6-\u06ed\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]',
);
static final _urls = RegExp(
r'''(?:https?://|tg://|telegram://|www\.)[^\s<>"')]+''',
caseSensitive: false,
);
static final _handles = RegExp(r'(^|[\s>(])@[a-zA-Z][a-zA-Z0-9_]{3,}');
static String _decode(String value) {
var result = value;
for (var i = 0; i < 3; i++) {
final decoded = result.replaceAllMapped(RegExp(r'(?:%[0-9a-fA-F]{2})+'), (
match,
) {
try {
return Uri.decodeComponent(match.group(0)!);
} on FormatException {
return match.group(0)!;
} on ArgumentError {
return match.group(0)!;
}
});
if (decoded == result) break;
result = decoded;
}
return result
.replaceAllMapped(
RegExp(r'&#(x[0-9a-f]+|[0-9]+);', caseSensitive: false),
(match) {
final entity = match.group(1)!;
final code = int.tryParse(
entity.startsWith(RegExp('[xX]')) ? entity.substring(1) : entity,
radix: entity.startsWith(RegExp('[xX]')) ? 16 : 10,
);
return code != null && code >= 0 && code <= 0x10ffff
? String.fromCharCode(code)
: '';
},
)
.replaceAll('&amp;', '&')
.replaceAll('&nbsp;', ' ');
}
static String _normal(String value) =>
_decode(value)
.toLowerCase()
.replaceAll('ي', 'ی')
.replaceAll('ى', 'ی')
.replaceAll('ك', 'ک')
.replaceAll(_formatting, '');
static String _compact(String value) =>
_normal(value).replaceAll(RegExp(r'[\s_\-\u0640]+'), '');
static bool _brand(String value) {
final compact = _compact(value);
return compact.contains('ملودیباکس') ||
compact.contains('ملودیبکس') ||
compact.contains('melodybox') ||
compact.contains('melodibox') ||
compact.contains('نورتون') ||
compact.contains('نورتن') ||
compact.contains('norton');
}
static bool allowsText(String value) {
final normal = _normal(value);
final compact = _compact(value);
if (_brand(value) ||
normal.contains('تلگرام') ||
RegExp(r'\btelegram\b').hasMatch(normal) ||
_handles.hasMatch(normal)) {
return false;
}
if (RegExp(
r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)',
dotAll: true,
).hasMatch(normal) ||
RegExp(
r'(آپدیت|بروزرسانی|بهروز رسانی|بهروزرسانی).{0,20}(برنامه|اپلیکیشن|سیمبا)',
dotAll: true,
).hasMatch(normal) ||
RegExp(
r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)',
dotAll: true,
).hasMatch(normal)) {
return false;
}
if ((compact.contains('originalapp') ||
compact.contains('moviebox') ||
compact.contains('سیمبا')) &&
(compact.contains('support') ||
compact.contains('news') ||
compact.contains('پشتیبانی'))) {
return false;
}
return _urls
.allMatches(_decode(value))
.every((match) => allowsUrl(match.group(0)!));
}
static String sanitizeText(String value) =>
allowsText(value) ? value.replaceAll(_formatting, '').trim() : '';
static bool allowsUrl(String value) {
if (value.isEmpty) return true;
if (value != value.trim() || RegExp(r'[\x00-\x20\x7f]').hasMatch(value)) {
return false;
}
final decoded = _decode(value);
if (_brand(decoded) || decoded.contains('\\')) return false;
try {
final uri = Uri.parse(
decoded.startsWith('www.') ? 'https://$decoded' : decoded,
);
if (uri.userInfo.isNotEmpty ||
(uri.hasScheme &&
!{'http', 'https'}.contains(uri.scheme.toLowerCase()))) {
return false;
}
final host = uri.host.toLowerCase();
if ([
't.me',
'telegram.me',
'telegram.dog',
'telegram.org',
'telegra.ph',
].any((blocked) => host == blocked || host.endsWith('.$blocked'))) {
return false;
}
final path = _normal(uri.path);
if (RegExp(r'\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)(/|$)')
.hasMatch(path)) {
return false;
}
if (RegExp(
r'(^|/)(original[-_]?app|moviebox|simba)/(support|news|updates?|downloads?)(/|$)',
).hasMatch(path)) {
return false;
}
if (RegExp(
r'(^|/)(app[-_]?updates?|download[-_]?apk|latest[-_]?version)(/|$)',
).hasMatch(path)) {
return false;
}
return true;
} on FormatException {
return false;
}
}
static bool allowsApiPath(Uri uri) {
final path = _normal(uri.path);
final actions = (uri.queryParametersAll['action'] ?? const <String>[]).map(
_normal,
);
if (RegExp(
r'(^|/)(comments?|replies|reply|channels?|public[-_]?playlists?|news|updates?|support)([-_][a-z0-9_-]+)?(\.php|/|$)',
).hasMatch(path)) {
return false;
}
return actions.every(
(action) =>
!{
'comment',
'comments',
'add-comment',
'reply',
'replies',
'pasokh',
'showlist',
'show-list_fav',
'public-playlists',
'playlist-items',
'app-update',
'app-news',
'telegram',
'support',
}.contains(action) &&
!RegExp(r'(^|[-_])(comments?|replies|reply|pasokh)([-_]|$)')
.hasMatch(action),
);
}
static bool _removedRecord(Map<String, dynamic> value) {
final type = _normal('${value['type'] ?? value['kind'] ?? ''}');
if (_removedTypes.contains(type)) return true;
for (final key in [
'title',
'name',
'description',
'note',
'message',
'text',
'content',
'body',
'summary',
'label',
]) {
final text = value[key];
if (text is String && !allowsText(text)) return true;
}
for (final key in [
'link',
'url',
'website',
'button_url',
'download_url',
'apk_url',
]) {
final url = value[key];
if (url is String && !allowsUrl(url)) return true;
}
return false;
}
static bool allowsTitleJson(Map<String, dynamic> json) =>
!_removedRecord(json);
/// Returns a fresh JSON tree; removed records/fields never become blank cards.
static dynamic sanitizeJson(dynamic value) {
if (value is Map) {
final map = Map<String, dynamic>.from(value);
if (_removedRecord(map)) return null;
final result = <String, dynamic>{};
for (final entry in map.entries) {
if (_removedKeys.contains(_normal(entry.key))) continue;
final item = sanitizeJson(entry.value);
if (item != null) result[entry.key] = item;
}
return result;
}
if (value is List) {
return value.map(sanitizeJson).where((v) => v != null).toList();
}
if (value is String) return allowsText(value) ? value : null;
return value;
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'api_service.dart';
import 'content_policy.dart';
class HomeBanner {
const HomeBanner._({
required this.titleEn,
required this.titleFa,
required this.subtitleEn,
required this.subtitleFa,
required this.target,
required this.isAdvertisement,
this.image,
});
final String titleEn, titleFa, subtitleEn, subtitleFa;
final Uri target;
final Uri? image;
final bool isAdvertisement;
String title(bool persian) => persian ? titleFa : titleEn;
String subtitle(bool persian) => persian ? subtitleFa : subtitleEn;
static final telegram = HomeBanner._(
titleEn: 'ReelBox updates',
titleFa: 'تازه‌های ریل‌باکس',
subtitleEn: 'Updates and announcements',
subtitleFa: 'به‌روزرسانی‌ها و اطلاعیه‌ها',
target: Uri.parse('https://t.me/ReelBoxApp'),
isAdvertisement: false,
);
}
/// Only configured advertisements are parsed. The official Telegram entry is
/// fixed in the client and can never be replaced or relabelled by server data.
List<HomeBanner> parseHomeBanners(dynamic raw) {
final result = <HomeBanner>[HomeBanner.telegram];
if (raw is! Map || raw['banners'] is! List) return result;
for (final row in (raw['banners'] as List).take(20)) {
if (result.length == 6) break;
if (row is! Map) continue;
final en = _label(row['title_en'], 80);
final fa = _label(row['title_fa'] ?? row['title_en'], 80);
final subtitleEn = _label(row['subtitle_en'] ?? '', 180);
final subtitleFa = _label(
row['subtitle_fa'] ?? row['subtitle_en'] ?? '',
180,
);
final target = _https(row['target_url']);
final imageValue = row['image_url'];
final image = imageValue == null || imageValue == ''
? null
: _https(imageValue);
if (en == null ||
en.isEmpty ||
fa == null ||
fa.isEmpty ||
subtitleEn == null ||
subtitleFa == null ||
target == null ||
(imageValue != null && imageValue != '' && image == null)) {
continue;
}
result.add(
HomeBanner._(
titleEn: en,
titleFa: fa,
subtitleEn: subtitleEn,
subtitleFa: subtitleFa,
target: target,
image: image,
isAdvertisement: true,
),
);
}
return List.unmodifiable(result);
}
String? _label(dynamic value, int limit) {
if (value is! String ||
value.length > limit ||
RegExp(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]').hasMatch(value) ||
!ContentPolicy.allowsText(value)) {
return null;
}
return value.trim();
}
Uri? _https(dynamic value) {
if (value is! String ||
value.length > 2048 ||
RegExp(r'[\x00-\x20\x7f\\]').hasMatch(value) ||
RegExp(
r'%(?![0-9a-fA-F]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)',
caseSensitive: false,
).hasMatch(value) ||
!ContentPolicy.allowsUrl(value)) {
return null;
}
try {
final uri = Uri.parse(value);
if (uri.scheme != 'https' ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.port != 443 ||
uri.host.length > 253 ||
!uri.host
.split('.')
.every(
(part) =>
RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$')
.hasMatch(part),
)) {
return null;
}
return uri;
} on FormatException {
return null;
}
}
/// Uses only the existing trusted metadata transport, never a config URL.
class HomeBannerService {
const HomeBannerService(this.api);
final ApiService api;
Future<List<HomeBanner>> load() async {
try {
return parseHomeBanners(await api.get('banners/'));
} catch (_) {
// Announcements remain reachable when the metadata service is offline.
return [HomeBanner.telegram];
}
}
}
+202
View File
@@ -0,0 +1,202 @@
/// Public service metadata. Source visibility is not an authorization grant.
library;
import 'content_policy.dart';
Map<String, dynamic> _cleanJson(Map<String, dynamic> value) {
final clean = ContentPolicy.sanitizeJson(value);
return clean is Map ? Map<String, dynamic>.from(clean) : <String, dynamic>{};
}
String _text(dynamic value, [String fallback = '']) =>
value is String || value is num || value is bool ? '$value' : fallback;
int _integer(dynamic value) {
if (value is int) return value;
if (value is num) return value.isFinite ? value.toInt() : 0;
return int.tryParse(_text(value).trim()) ?? 0;
}
double _decimal(dynamic value) {
final parsed = value is num
? value.toDouble()
: double.tryParse(_text(value).trim());
return parsed != null && parsed.isFinite ? parsed : 0;
}
List<T> _records<T>(dynamic value, T Function(Map<String, dynamic>) parse) =>
value is List
? value
.whereType<Map>()
.map((item) => parse(Map<String, dynamic>.from(item)))
.toList()
: <T>[];
class Tag {
final int id;
final String title, image;
const Tag({this.id = 0, this.title = '', this.image = ''});
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
id: _integer(json['id']),
title: ContentPolicy.sanitizeText(_text(json['title'])),
image: ContentPolicy.allowsUrl(_text(json['image']))
? _text(json['image'])
: '',
);
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'image': image};
}
class MediaSource {
final int id;
final String quality, type, url;
const MediaSource({
this.id = 0,
this.quality = '',
this.type = '',
this.url = '',
});
factory MediaSource.fromJson(Map<String, dynamic> json) => MediaSource(
id: _integer(json['id']),
quality: ContentPolicy.sanitizeText(_text(json['quality'])),
type: _text(json['type']),
url: ContentPolicy.allowsUrl(_text(json['url'])) ? _text(json['url']) : '',
);
bool get isTrailer =>
RegExp(
r'تیزر|تریلر|trailer|teaser',
caseSensitive: false,
).hasMatch(quality) ||
type.toLowerCase() == 'trailer';
Map<String, dynamic> toJson() => {
'id': id,
'quality': quality,
'type': type,
'url': url,
};
}
class EpisodeRecord {
final int id;
final String title, description, duration;
final List<MediaSource> sources;
final Map<String, dynamic> raw;
const EpisodeRecord({
this.id = 0,
this.title = '',
this.description = '',
this.duration = '',
this.sources = const [],
this.raw = const {},
});
factory EpisodeRecord.fromJson(Map<String, dynamic> value) {
final json = _cleanJson(value);
return EpisodeRecord(
id: _integer(json['id']),
title: _text(json['title']),
description: _text(json['description']),
duration: _text(json['duration']),
sources: _records(json['sources'], MediaSource.fromJson),
raw: Map.unmodifiable(json),
);
}
Map<String, dynamic> toJson() => {
...raw,
'id': id,
'title': title,
'description': description,
'duration': duration,
'sources': sources.map((source) => source.toJson()).toList(),
};
}
class SeasonRecord {
final int id;
final String title;
final List<EpisodeRecord> episodes;
final Map<String, dynamic> raw;
const SeasonRecord({
this.id = 0,
this.title = '',
this.episodes = const [],
this.raw = const {},
});
factory SeasonRecord.fromJson(Map<String, dynamic> value) {
final json = _cleanJson(value);
return SeasonRecord(
id: _integer(json['id']),
title: _text(json['title']),
episodes: _records(json['episodes'], EpisodeRecord.fromJson),
raw: Map.unmodifiable(json),
);
}
Map<String, dynamic> toJson() => {
...raw,
'id': id,
'title': title,
'episodes': episodes.map((episode) => episode.toJson()).toList(),
};
}
class TitleRecord {
final int id, year;
final String type, title, description, duration, classification, image, cover;
final double imdb, rating;
final List<Tag> genres, countries;
final List<MediaSource> sources;
final Map<String, dynamic> raw;
const TitleRecord({
this.id = 0,
this.type = 'movie',
this.title = '',
this.description = '',
this.duration = '',
this.classification = '',
this.image = '',
this.cover = '',
this.year = 0,
this.imdb = 0,
this.rating = 0,
this.genres = const [],
this.countries = const [],
this.sources = const [],
this.raw = const {},
});
factory TitleRecord.fromJson(Map<String, dynamic> value) {
final json = _cleanJson(value);
return TitleRecord(
id: _integer(json['id'] ?? json['posted_id'] ?? json['movie_id']),
type: _text(json['type'] ?? json['kind'], 'movie'),
title: _text(json['title'] ?? json['name']),
description: _text(json['description']),
duration: _text(json['duration']),
classification: _text(json['classification']),
image: _text(json['image'] ?? json['img'] ?? json['poster']),
cover: _text(json['cover']),
year: _integer(json['year']),
imdb: _decimal(json['imdb']),
rating: _decimal(json['rating']),
genres: _records(json['genres'], Tag.fromJson),
countries: _records(json['country'] ?? json['countries'], Tag.fromJson),
sources: _records(json['sources'], MediaSource.fromJson),
raw: Map.unmodifiable(json),
);
}
bool get isSeries => type == 'serie' || type == 'series';
bool get isChannel => type == 'channel';
Map<String, dynamic> toJson() => {
...raw,
'id': id,
'type': type,
'title': title,
'description': description,
'duration': duration,
'classification': classification,
'image': image,
'cover': cover,
'year': year,
'imdb': imdb,
'rating': rating,
'genres': genres.map((tag) => tag.toJson()).toList(),
'country': countries.map((tag) => tag.toJson()).toList(),
'sources': sources.map((source) => source.toJson()).toList(),
};
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'app.dart';
import 'bootstrap.dart';
import 'data/api_service.dart';
import 'media/download_service.dart';
import 'state/app_state.dart';
export 'app.dart' show MyApp;
void main() {
WidgetsFlutterBinding.ensureInitialized();
LicenseRegistry.addLicense(() async* {
final notice = await rootBundle.loadString(
'assets/licenses/LIBVLC-NOTICE.txt',
);
final license = await rootBundle.loadString('assets/licenses/LGPL-2.1.txt');
yield LicenseEntryWithLineBreaks([
'LibVLC for Android',
], '$notice\n\n$license');
});
final state = AppState(api: ApiService());
runApp(
ReelboxBootstrap(
initialize: () async {
// Native engine failure is recoverable too, not outside the startup guard.
MediaKit.ensureInitialized();
await state.initialize(discover: false);
unawaited(
state.api.initialize().catchError((Object _) {
debugPrint(
'Server discovery unavailable; keeping the trusted API base.',
);
}),
);
if (!kIsWeb) {
DownloadService.instance.bindSettings(state);
unawaited(
DownloadService.instance.initialize().catchError((Object _) {
debugPrint('Background downloader initialization unavailable.');
}),
);
}
},
builder: (_) => MyApp(state: state),
),
);
}
+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;
}
File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:path_provider/path_provider.dart';
class CacheSnapshot {
const CacheSnapshot({required this.memoryBytes, required this.diskBytes});
final int memoryBytes, diskBytes;
int get totalBytes => memoryBytes + diskBytes;
}
/// Disposable cache only. Never sweep the temporary/app-support directories:
/// downloads, pending packages and private library data may also live there.
class CacheService {
CacheService({Future<Directory> Function()? directory})
: _directory = directory ?? _defaultDirectory;
final Future<Directory> Function() _directory;
static Future<Directory> _defaultDirectory() async =>
Directory('${(await getTemporaryDirectory()).path}/reelbox-cache');
Future<CacheSnapshot> measure() async {
var diskBytes = 0;
if (!kIsWeb) {
final directory = await _directory();
// Ignore links even when the cache root itself is one.
if (await FileSystemEntity.type(directory.path, followLinks: false) ==
FileSystemEntityType.directory) {
await for (final entry in directory.list(
recursive: true,
followLinks: false,
)) {
if (await FileSystemEntity.type(entry.path, followLinks: false) ==
FileSystemEntityType.file) {
try {
diskBytes += await File(entry.path).length();
} on FileSystemException {
// An evicted file may disappear during measurement.
}
}
}
}
}
return CacheSnapshot(
memoryBytes: PaintingBinding.instance.imageCache.currentSizeBytes,
diskBytes: diskBytes,
);
}
Future<void> clear() async {
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
if (kIsWeb) return;
final directory = await _directory();
if (await FileSystemEntity.type(directory.path, followLinks: false) !=
FileSystemEntityType.directory) {
return;
}
// Only regular files. Do not follow or delete links to other storage.
await for (final entry in directory.list(
recursive: true,
followLinks: false,
)) {
if (await FileSystemEntity.type(entry.path, followLinks: false) ==
FileSystemEntityType.file) {
try {
await File(entry.path).delete();
} on FileSystemException {
if (await File(entry.path).exists()) rethrow;
}
}
}
}
}
+2363
View File
File diff suppressed because it is too large. Load diff
+865
View File
@@ -0,0 +1,865 @@
part of 'catalog.dart';
/// Account-free discovery, using the observed routes rather than invented tags.
class DiscoveryDirectoryPage extends StatelessWidget {
const DiscoveryDirectoryPage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Explore more', 'کشف بیشتر'))),
body: ListView(
children: [
for (final preset in <(String, String, String, int, String)>[
('Best titles', 'بهترین عنوان‌ها', 'mixed', 0, 'rating'),
('Newest titles', 'جدیدترین عنوان‌ها', 'mixed', 0, 'created'),
('Popular titles', 'عنوان‌های محبوب', 'mixed', 0, 'rating'),
('Top 250 movies', '۲۵۰ فیلم برتر', 'movie', 0, 'imdb'),
('Top 250 series', '۲۵۰ سریال برتر', 'serie', 0, 'imdb'),
('Korean series', 'سریال‌های کره‌ای', 'serie', 36, 'created'),
(
'Chinese / Japanese series',
'سریال‌های چینی / ژاپنی',
'serie',
37,
'created',
),
('Turkish series', 'سریال‌های ترکی', 'serie', 35, 'created'),
('Indian series', 'سریال‌های هندی', 'serie', 38, 'created'),
('Indian movies', 'فیلم‌های هندی', 'movie', 38, 'created'),
(
'Newest movies by year',
'جدیدترین فیلم‌ها بر اساس سال',
'movie',
0,
'year',
),
(
'Newest series by year',
'جدیدترین سریال‌ها بر اساس سال',
'serie',
0,
'year',
),
if (!AppScope.of(context).usesIranCatalogue) ...[
('Most-viewed movies', 'پربازدیدترین فیلم‌ها', 'movie', 0, 'views'),
(
'Most-viewed series',
'پربازدیدترین سریال‌ها',
'serie',
0,
'views',
),
],
])
ListTile(
leading: const Icon(Icons.movie_filter_outlined),
title: Text(tr(context, preset.$1, preset.$2)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
BrowsePage(
initialKind: preset.$3,
initialGenre: preset.$4,
initialOrder: preset.$5,
heading: tr(context, preset.$1, preset.$2),
),
),
),
const Divider(),
for (final entry in <(String, String, Widget)>[
(
'Miniseries',
'مینی سریال',
const SearchPage(initialQuery: 'مینی سریال'),
),
('Sitcoms', 'سیتکام', const SearchPage(initialQuery: 'سیتکام')),
('Actors', 'بازیگران', const ActorsPage()),
('Countries', 'کشورها', const CountriesPage()),
(
'Themes & subjects',
'موضوع‌ها',
const SubjectDirectoryPage(kind: DiscoveryDirectory.themes),
),
(
'Franchises',
'فرنچایزها',
const SubjectDirectoryPage(kind: DiscoveryDirectory.franchises),
),
(
'Most-shared titles',
'عنوان‌های بیشتر اشتراک‌گذاشته‌شده',
const SharedTitlesPage(),
),
(
'New discoveries',
'تازه‌های کشف',
const SharedTitlesPage(newest: true),
),
('Editor picks', 'پیشنهاد سردبیر', const CuratedPage()),
(
'Best collections',
'مجموعه‌های برتر',
const CuratedPage(best: true),
),
(
'Best by year',
'بهترین‌های هر سال',
const RecommendationsPage(byYear: true),
),
])
ListTile(
title: Text(tr(context, entry.$1, entry.$2)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(context, entry.$3),
),
],
),
);
}
class _DiscoveryHomeShelves extends StatefulWidget {
const _DiscoveryHomeShelves();
@override
State<_DiscoveryHomeShelves> createState() => _DiscoveryHomeShelvesState();
}
class _DiscoveryHomeShelvesState extends State<_DiscoveryHomeShelves> {
Future<List<Map<String, dynamic>>>? _actors;
Future<List<Tag>>? _countries;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final api = AppScope.read(context).api;
_actors ??= api.actors();
_countries ??= api.countries();
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
child: Row(
children: [
Expanded(
child: _heading(
context,
tr(context, 'Popular actors', 'بازیگران محبوب'),
),
),
TextButton(
onPressed: () => _push(context, const ActorsPage()),
child: Text(tr(context, 'View all', 'نمایش همه')),
),
],
),
),
AsyncResult<List<Map<String, dynamic>>>(
future: _actors!,
onRetry: () => setState(() {
_actors = AppScope.read(context).api.actors();
}),
builder: (items) => items.isEmpty
? const SizedBox.shrink()
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _PeopleGrid(items: items.take(12).toList()),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
child: Row(
children: [
Expanded(
child: _heading(
context,
tr(context, 'Discover by country', 'کشف بر اساس کشور'),
),
),
TextButton(
onPressed: () => _push(context, const CountriesPage()),
child: Text(tr(context, 'View all', 'نمایش همه')),
),
],
),
),
AsyncResult<List<Tag>>(
future: _countries!,
onRetry: () => setState(() {
_countries = AppScope.read(context).api.countries();
}),
builder: (items) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final country
in items
.where(
(tag) =>
tag.id > 0 && ContentPolicy.allowsText(tag.title),
)
.take(16))
ActionChip(
label: Text(country.title),
avatar: const Icon(Icons.public, size: 16),
onPressed: () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialCountry: country.id,
heading: country.title,
),
),
),
],
),
),
),
],
);
}
class CountriesPage extends StatefulWidget {
const CountriesPage({super.key});
@override
State<CountriesPage> createState() => _CountriesPageState();
}
class _CountriesPageState extends State<CountriesPage> {
Future<List<Tag>>? _future;
String _query = '';
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.read(context).api.countries();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Countries', 'کشورها'))),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
onChanged: (value) =>
setState(() => _query = value.trim().toLowerCase()),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: tr(context, 'Find a country', 'جستجوی کشور'),
),
),
),
Expanded(
child: AsyncResult<List<Tag>>(
future: _future!,
onRetry: () => setState(() {
_future = AppScope.read(context).api.countries();
}),
builder: (items) {
final visible = items
.where(
(tag) =>
tag.id > 0 &&
ContentPolicy.allowsText(tag.title) &&
tag.title.toLowerCase().contains(_query),
)
.toList();
return visible.isEmpty
? EmptyView(
title: tr(
context,
'No countries found',
'کشوری پیدا نشد',
),
)
: ListView(
children: [
for (final country in visible)
ListTile(
leading: const Icon(Icons.public),
title: Text(country.title),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialCountry: country.id,
heading: country.title,
),
),
),
],
);
},
),
),
],
),
);
}
class ActorsPage extends StatefulWidget {
const ActorsPage({super.key});
@override
State<ActorsPage> createState() => _ActorsPageState();
}
class _ActorsPageState extends State<ActorsPage> {
String query = '';
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Actors', 'بازیگران'))),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
textInputAction: TextInputAction.search,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: tr(context, 'Search actors', 'جستجوی بازیگران'),
),
onSubmitted: (value) => setState(() => query = value.trim()),
),
),
Expanded(
child: _PagedObjects(
key: ValueKey(query),
load: (page) =>
AppScope.read(context).api.actors(page: page, search: query),
builder: (items) => _PeopleGrid(items: items),
),
),
],
),
);
}
class _PeopleGrid extends StatelessWidget {
const _PeopleGrid({required this.items});
final List<Map<String, dynamic>> items;
@override
Widget build(BuildContext context) => Wrap(
spacing: 8,
runSpacing: 12,
children: [
for (final person
in items
.map(actorMetadata)
.where((row) => ContentPolicy.allowsText('${row['name']}')))
SizedBox(
width: 125,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _push(context, ActorTitlesPage(person: person)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
ClipOval(
child: SizedBox(
width: 80,
height: 80,
child: _text(person, ['image']).isEmpty
? const ColoredBox(
color: Color(0xFF252733),
child: Icon(Icons.person_outline, size: 44),
)
: MediaArtwork(url: _text(person, ['image'])),
),
),
const SizedBox(height: 8),
Text(
_text(person, ['name']),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
if (_text(person, ['role']).isNotEmpty)
Text(
ContentPolicy.sanitizeText(_text(person, ['role'])),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
),
),
],
);
}
class ActorTitlesPage extends StatefulWidget {
const ActorTitlesPage({super.key, required this.person});
final Map<String, dynamic> person;
@override
State<ActorTitlesPage> createState() => _ActorTitlesPageState();
}
class _ActorTitlesPageState extends State<ActorTitlesPage> {
ActorFilmography? _loader;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final person = actorMetadata(widget.person);
_loader ??= ActorFilmography(
CatalogDiscovery(AppScope.read(context).api),
id: _id(person, ['id']),
name: _text(person, ['name']),
);
}
@override
Widget build(BuildContext context) {
final person = actorMetadata(widget.person);
return Scaffold(
appBar: AppBar(
title: Text(ContentPolicy.sanitizeText(_text(person, ['name']))),
),
body: _PagedTitles(
load: _loader!.load,
headerBuilder: () => Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipOval(
child: SizedBox(
width: 86,
height: 86,
child: _text(person, ['image']).isEmpty
? const Icon(Icons.person_outline, size: 60)
: MediaArtwork(url: _text(person, ['image'])),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final field in [
('role', 'Role', 'نقش'),
('born', 'Born', 'تولد'),
('height', 'Height', 'قد'),
])
if (_text(person, [field.$1]).isNotEmpty)
Text(
'${tr(context, field.$2, field.$3)}: ${ContentPolicy.sanitizeText(_text(person, [field.$1]))}',
),
],
),
),
],
),
if (_text(person, ['bio']).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: _Synopsis(
text: ContentPolicy.sanitizeText(_text(person, ['bio'])),
),
),
if (_loader!.usingNameFallback)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
tr(
context,
'Filmography unavailable. Showing title search by actor name.',
'فیلموگرافی موجود نیست. نتایج جستجوی نام بازیگر نمایش داده می‌شود.',
),
),
),
],
),
),
),
);
}
}
class SubjectDirectoryPage extends StatelessWidget {
const SubjectDirectoryPage({super.key, required this.kind});
final DiscoveryDirectory kind;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
kind == DiscoveryDirectory.themes
? tr(context, 'Themes & subjects', 'موضوع‌ها')
: tr(context, 'Franchises', 'فرنچایزها'),
),
),
body: _PagedObjects(
load: (page) =>
CatalogDiscovery(AppScope.read(context).api)
.directory(kind, page: page),
builder: (items) => Column(
children: [
for (final item in items.where(
(row) => ContentPolicy.allowsText(_text(row, ['title'])),
))
ListTile(
leading: Icon(
kind == DiscoveryDirectory.themes
? Icons.topic_outlined
: Icons.collections_bookmark_outlined,
),
title: Text(_text(item, ['title'])),
subtitle:
kind == DiscoveryDirectory.franchises &&
_text(item, ['img']).isNotEmpty
? Text(ContentPolicy.sanitizeText(_text(item, ['img'])))
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
SearchPage(initialQuery: _text(item, ['title'])),
),
),
],
),
),
);
}
class SharedTitlesPage extends StatelessWidget {
const SharedTitlesPage({super.key, this.newest = false});
final bool newest;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
newest
? tr(context, 'New discoveries', 'تازه‌های کشف')
: tr(
context,
'Most-shared titles',
'عنوان‌های بیشتر اشتراک‌گذاشته‌شده',
),
),
),
body: _PagedTitles(
load: (page) =>
CatalogDiscovery(AppScope.read(context).api)
.sharedTitles(page: page, newest: newest),
),
);
}
class CuratedPage extends StatefulWidget {
const CuratedPage({super.key, this.best = false});
final bool best;
@override
State<CuratedPage> createState() => _CuratedPageState();
}
class _CuratedPageState extends State<CuratedPage> {
Future<List<Map<String, dynamic>>>? future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
future ??= AppScope.read(context).api.curatedLinks(best: widget.best);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
widget.best
? tr(context, 'Best collections', 'مجموعه‌های برتر')
: tr(context, 'Editor picks', 'پیشنهاد سردبیر'),
),
),
body: AsyncResult<List<Map<String, dynamic>>>(
future: future!,
onRetry: () => setState(() {
future = AppScope.read(context).api.curatedLinks(best: widget.best);
}),
builder: (items) {
final visible = items
.where(
(row) =>
ContentPolicy.allowsText(_text(row, ['title'])) &&
ContentPolicy.allowsUrl(_text(row, ['link'])),
)
.toList();
return visible.isEmpty
? EmptyView(
title: tr(
context,
'No collections returned',
'مجموعه‌ای دریافت نشد',
),
)
: ListView(
children: [
for (final item in visible)
ListTile(
leading: const Icon(Icons.auto_awesome_outlined),
title: Text(_text(item, ['title'])),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
_CuratedContents(
title: _text(item, ['title']),
link: _text(item, ['link']),
),
),
),
],
);
},
),
);
}
class _CuratedContents extends StatelessWidget {
const _CuratedContents({required this.title, required this.link});
final String title, link;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(title)),
body: _PagedTitles(
load: (page) =>
AppScope.read(context).api.curatedContent(link, page: page),
),
);
}
class _PagedObjects extends StatefulWidget {
const _PagedObjects({super.key, required this.load, required this.builder});
final Future<List<Map<String, dynamic>>> Function(int) load;
final Widget Function(List<Map<String, dynamic>>) builder;
@override
State<_PagedObjects> createState() => _PagedObjectsState();
}
class _PagedObjectsState extends State<_PagedObjects> {
final items = <Map<String, dynamic>>[];
final _scroll = ScrollController();
int page = 1, _emptyPages = 0;
bool busy = false, end = false;
Object? error;
@override
void initState() {
super.initState();
_scroll.addListener(_nearEnd);
load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _nearEnd() {
if (_emptyPages < 4 &&
error == null &&
_scroll.hasClients &&
_scroll.position.extentAfter < 350) {
load();
}
}
Future<void> load() async {
if (busy || end) return;
setState(() {
busy = true;
error = null;
});
try {
final result = await widget.load(page);
if (!mounted) return;
final known = items.map(jsonEncode).toSet();
final fresh = result.where((row) => known.add(jsonEncode(row))).toList();
setState(() {
items.addAll(fresh);
end = result is DiscoveryObjectPage
? result.nextPage == null
: result.isEmpty;
page = result is DiscoveryObjectPage
? result.nextPage ?? page
: page + 1;
_emptyPages = fresh.isEmpty ? _emptyPages + 1 : 0;
});
} catch (_) {
if (mounted) {
setState(
() => error = const ApiException('Could not load. Please retry.'),
);
}
} finally {
if (mounted) {
setState(() => busy = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _nearEnd();
});
}
}
}
Future<void> _refresh() async {
if (busy) return;
setState(() {
items.clear();
page = 1;
end = false;
error = null;
_emptyPages = 0;
});
await load();
}
@override
Widget build(BuildContext context) => RefreshIndicator(
onRefresh: _refresh,
child: ListView(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
Text(
'${items.length} ${tr(context, 'items loaded', 'مورد بارگذاری شده')}',
),
widget.builder(items),
if (busy)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
if (error != null)
EmptyView(
title: tr(context, 'Could not load', 'بارگذاری ممکن نشد'),
onRetry: load,
),
if (!busy && error == null && end && items.isEmpty)
EmptyView(
title: tr(
context,
'Nothing returned by the service',
'سرویس موردی برنگرداند',
),
onRetry: _refresh,
),
if (!busy && !end && error == null)
OutlinedButton(
onPressed: () {
_emptyPages = 0;
load();
},
child: Text(tr(context, 'Load more', 'نمایش بیشتر')),
),
],
),
);
}
class _ContinueWatchingShelf extends StatelessWidget {
const _ContinueWatchingShelf();
@override
Widget build(BuildContext context) {
final known = <String>{};
final entries = AppScope.of(context).history
.where(
(entry) =>
entry.positionSeconds >= 5 &&
!entry.title.isChannel &&
ContentPolicy.allowsTitleJson(entry.title.toJson()) &&
known.add(entry.title.libraryKey),
)
.take(6)
.toList();
if (entries.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: _heading(
context,
tr(context, 'Continue watching', 'ادامه تماشا'),
),
),
for (final entry in entries)
ListTile(
leading: const Icon(Icons.play_circle_outline),
title: Text(entry.title.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.episodeTitle.isNotEmpty) Text(entry.episodeTitle),
LinearProgressIndicator(value: entry.progress),
Text('${(entry.progress * 100).round()}%'),
],
),
onTap: () => _continueCurrentHistory(context, entry),
trailing: IconButton(
tooltip: tr(context, 'Title details', 'جزئیات عنوان'),
icon: const Icon(Icons.info_outline),
onPressed: () => goTitle(context, entry.title),
),
),
],
);
}
}
Future<void> _continueCurrentHistory(
BuildContext context,
WatchEntry entry,
) async {
try {
final api = AppScope.read(context).api;
final title = await api.poster(entry.title.id);
if (!ContentPolicy.allowsTitleJson(title.toJson())) {
throw const ApiException('Unavailable');
}
EpisodeRecord? episode;
var playlist = <EpisodeRecord>[];
if (entry.episodeId != null) {
playlist = (await api.seasons(title.id))
.expand((season) => season.episodes)
.toList();
episode = playlist
.where((item) => item.id == entry.episodeId)
.firstOrNull;
if (episode == null) throw const ApiException('Episode unavailable');
}
final sources = (episode?.sources ?? title.sources)
.where((source) => ContentPolicy.allowsUrl(source.url))
.toList();
final source = currentHistorySource(sources, entry.sourceUrl);
if (source == null) throw const ApiException('Source unavailable');
if (context.mounted) {
await _play(
context,
title,
source,
episode: episode,
playlist: playlist,
alternatives: sources,
);
}
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not refresh the current playback source. Open title details and retry.',
'به‌روزرسانی منبع پخش ممکن نشد. جزئیات عنوان را باز کنید و دوباره تلاش کنید.',
),
);
}
}
}
+504
View File
@@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/content_policy.dart';
import '../state/app_state.dart';
import 'catalog.dart';
enum ToastKind { info, success, error }
void toast(
BuildContext context,
String message, {
ToastKind kind = ToastKind.info,
}) {
if (!context.mounted) return;
final colors = Theme.of(context).colorScheme;
final text = ContentPolicy.sanitizeText(message);
final background = switch (kind) {
ToastKind.error => colors.errorContainer,
ToastKind.success => colors.tertiaryContainer,
ToastKind.info => colors.inverseSurface,
};
final foreground = switch (kind) {
ToastKind.error => colors.onErrorContainer,
ToastKind.success => colors.onTertiaryContainer,
ToastKind.info => colors.onInverseSurface,
};
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: background,
showCloseIcon: true,
closeIconColor: foreground,
content: Row(
children: [
Icon(
switch (kind) {
ToastKind.error => Icons.error_outline,
ToastKind.success => Icons.check_circle_outline,
ToastKind.info => Icons.info_outline,
},
color: foreground,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
text.isEmpty
? tr(
context,
'This content is not available',
'این محتوا در دسترس نیست',
)
: text,
style: TextStyle(color: foreground),
),
),
],
),
),
);
}
void goTitle(BuildContext context, TitleRecord title) {
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
title.id <= 0 ||
title.title.isEmpty) {
toast(
context,
tr(context, 'This content is not available', 'این محتوا در دسترس نیست'),
);
return;
}
Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => TitlePage(title: title)));
}
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
required this.title,
this.message,
this.icon = Icons.movie_outlined,
this.onRetry,
});
final String title;
final String? message;
final IconData icon;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 44, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
if (message != null) ...[
const SizedBox(height: 10),
Text(
message!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (onRetry != null) ...[
const SizedBox(height: 20),
FilledButton.tonalIcon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(tr(context, 'Try again', 'تلاش دوباره')),
),
],
],
),
),
);
}
class AsyncResult<T> extends StatelessWidget {
const AsyncResult({
super.key,
required this.future,
required this.builder,
this.onRetry,
});
final Future<T> future;
final Widget Function(T) builder;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) => FutureBuilder<T>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: Padding(
padding: EdgeInsets.all(48),
child: CircularProgressIndicator(),
),
);
}
if (snapshot.hasError) {
return EmptyView(
title: tr(
context,
'Could not load this page',
'بارگذاری صفحه ممکن نشد',
),
message: snapshot.error.toString(),
icon: Icons.wifi_off_rounded,
onRetry: onRetry,
);
}
if (!snapshot.hasData) {
return EmptyView(
title: tr(context, 'Nothing here yet', 'هنوز محتوایی وجود ندارد'),
);
}
return builder(snapshot.data as T);
},
);
}
class MediaArtwork extends StatelessWidget {
const MediaArtwork({
super.key,
required this.url,
this.fit = BoxFit.cover,
this.width,
this.height,
});
final String url;
final BoxFit fit;
final double? width, height;
@override
Widget build(BuildContext context) {
Widget fallback() => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.local_movies_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 32,
),
),
);
final dataSaver =
context
.dependOnInheritedWidgetOfExactType<AppScope>()
?.notifier
?.prefs
.dataSaver ??
false;
if (url.isEmpty || dataSaver || !ContentPolicy.allowsUrl(url)) {
return SizedBox(width: width, height: height, child: fallback());
}
return LayoutBuilder(
builder: (context, constraints) {
// A parent SizedBox often supplies thumbnail dimensions instead of the
// optional widget width. Decode for that slot, not the 480px fallback.
final logicalWidth = width ??
(constraints.hasBoundedWidth ? constraints.maxWidth : null);
final cacheWidth = logicalWidth == null || !logicalWidth.isFinite
? 480
: (logicalWidth * MediaQuery.devicePixelRatioOf(context))
.ceil()
.clamp(1, 1200);
return Image.network(
url,
fit: fit,
width: width,
height: height,
cacheWidth: cacheWidth,
errorBuilder: (_, _, _) =>
SizedBox(width: width, height: height, child: fallback()),
loadingBuilder: (_, child, progress) =>
progress == null ? child : fallback(),
);
},
);
}
}
class PosterCard extends StatelessWidget {
const PosterCard({super.key, required this.title, this.width = 132});
final TitleRecord title;
final double width;
@override
Widget build(BuildContext context) {
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
title.id <= 0 ||
title.title.isEmpty) {
return const SizedBox.shrink();
}
final state = AppScope.of(context);
return SizedBox(
width: width,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => goTitle(context, title),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
MediaArtwork(url: title.image, width: width),
if (title.imdb > 0)
PositionedDirectional(
top: 8,
start: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .78),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
child: Text(
'★ ${title.imdb.toStringAsFixed(1)}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFFFFD777),
fontWeight: FontWeight.w700,
),
),
),
),
),
PositionedDirectional(
bottom: 6,
end: 6,
child: IconButton.filledTonal(
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
padding: EdgeInsets.zero,
iconSize: 18,
tooltip: tr(context, 'Watchlist', 'تماشای بعدی'),
onPressed: () async {
try {
await state.toggleSaved(title);
} catch (error) {
if (context.mounted) {
toast(
context,
error.toString(),
kind: ToastKind.error,
);
}
}
},
icon: Icon(
state.hasSaved(title.id, kind: title.type)
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
),
),
),
],
),
),
),
const SizedBox(height: 9),
Text(
title.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(
[
if (title.year > 0) '${title.year}',
title.isSeries
? tr(context, 'Series', 'سریال')
: tr(context, 'Movie', 'فیلم'),
].join(' · '),
maxLines: 1,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
class PosterRail extends StatelessWidget {
const PosterRail({
super.key,
required this.title,
required this.items,
this.onMore,
});
final String title;
final List<TitleRecord> items;
final VoidCallback? onMore;
@override
Widget build(BuildContext context) {
final visible = items
.where(
(item) =>
item.id > 0 &&
item.title.isNotEmpty &&
ContentPolicy.allowsTitleJson(item.toJson()),
)
.toList();
if (visible.isEmpty || !ContentPolicy.allowsText(title)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 12, 12),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
if (onMore != null)
TextButton(
onPressed: onMore,
child: Text(tr(context, 'See all', 'نمایش همه')),
),
],
),
),
SizedBox(
height: 244,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20),
scrollDirection: Axis.horizontal,
itemCount: visible.length,
separatorBuilder: (_, _) => const SizedBox(width: 13),
itemBuilder: (_, index) => PosterCard(title: visible[index]),
),
),
],
),
);
}
}
class PosterGrid extends StatelessWidget {
const PosterGrid({super.key, required this.items, this.sliver = false});
final List<TitleRecord> items;
/// Compose directly into the owning viewport instead of measuring every row.
final bool sliver;
@override
Widget build(BuildContext context) {
final visible = items
.where(
(item) =>
item.id > 0 &&
item.title.isNotEmpty &&
ContentPolicy.allowsTitleJson(item.toJson()),
)
.toList();
if (visible.isEmpty) {
return sliver
? const SliverToBoxAdapter(child: SizedBox.shrink())
: const SizedBox.shrink();
}
final state = AppScope.of(context);
final Widget content;
if (state.prefs.compactCatalog) {
content = SliverList.separated(
itemCount: visible.length,
separatorBuilder: (_, _) => const Divider(height: 8),
itemBuilder: (context, index) {
final title = visible[index];
return ListTile(
key: ValueKey('compact-title-row:${title.libraryKey}'),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
minTileHeight: 96,
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: MediaArtwork(url: title.image, width: 56, height: 80),
),
title: Text(
title.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
if (title.year > 0) '${title.year}',
title.isSeries
? tr(context, 'Series', 'سریال')
: tr(context, 'Movie', 'فیلم'),
if (title.imdb > 0) 'IMDb ${title.imdb.toStringAsFixed(1)}',
].join(' · '),
),
onTap: () => goTitle(context, title),
trailing: IconButton(
tooltip: tr(context, 'Watchlist', 'تماشای بعدی'),
icon: Icon(
state.hasSaved(title.id, kind: title.type)
? Icons.bookmark
: Icons.bookmark_outline,
),
onPressed: () async {
try {
await state.toggleSaved(title);
} catch (error) {
if (context.mounted) {
toast(context, error.toString(), kind: ToastKind.error);
}
}
},
),
);
},
);
} else {
content = SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
childAspectRatio: .56,
mainAxisSpacing: 20,
crossAxisSpacing: 14,
),
itemCount: visible.length,
itemBuilder: (_, index) => PosterCard(title: visible[index]),
);
}
final padded = SliverPadding(
padding: const EdgeInsets.all(16),
sliver: content,
);
return sliver
? padded
: CustomScrollView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
slivers: [padded],
);
}
}
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/home_banners.dart';
import '../state/app_state.dart';
import 'common.dart';
/// Independent of home metadata loading/error UI so the official channel stays
/// visible and usable even before configuration has arrived.
class HomeBanners extends StatelessWidget {
const HomeBanners({super.key, required this.future});
final Future<List<HomeBanner>> future;
Future<void> _open(BuildContext context, Uri target) async {
try {
if (await launchUrl(target, mode: LaunchMode.externalApplication)) return;
} catch (_) {
// Do not expose platform exceptions, configuration values or URLs.
}
if (context.mounted) {
toast(
context,
tr(context, 'Could not open this link', 'باز کردن پیوند ممکن نشد'),
kind: ToastKind.error,
);
}
}
@override
Widget build(BuildContext context) => FutureBuilder<List<HomeBanner>>(
future: future,
builder: (context, snapshot) {
final banners = [
HomeBanner.telegram,
if (!snapshot.hasError)
...?snapshot.data?.where((item) => item.isAdvertisement).take(5),
];
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Column(
children: [
for (var i = 0; i < banners.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == banners.length - 1 ? 0 : 12,
),
child: _BannerCard(
banner: banners[i],
tapKey: Key(
banners[i].isAdvertisement
? 'home-banner-ad-$i'
: 'home-banner-telegram',
),
onTap: () => _open(context, banners[i].target),
),
),
],
),
);
},
);
}
class _BannerCard extends StatelessWidget {
const _BannerCard({
required this.banner,
required this.tapKey,
required this.onTap,
});
final HomeBanner banner;
final Key tapKey;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final persian = state.locale == 'fa';
final colors = Theme.of(context).colorScheme;
final subtitle = banner.subtitle(persian);
return Material(
color: colors.primary.withValues(alpha: .08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.primary.withValues(alpha: .22)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: tapKey,
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (banner.image != null && !state.prefs.dataSaver)
Image.network(
banner.image.toString(),
height: 112,
fit: BoxFit.cover,
cacheWidth: 700,
excludeFromSemantics: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
banner.isAdvertisement
? Icons.ad_units_outlined
: Icons.campaign_outlined,
color: colors.primary,
size: 18,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (banner.isAdvertisement) ...[
Text(
tr(context, 'Advertisement', 'تبلیغات'),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
],
Text(
banner.title(persian),
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colors.onSurfaceVariant),
),
],
],
),
),
const SizedBox(width: 8),
Icon(
Icons.open_in_new_rounded,
color: colors.primary,
size: 18,
),
],
),
),
],
),
),
);
}
}
+1518
View File
File diff suppressed because it is too large. Load diff