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