Files

2364 lines
84 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/api_service.dart';
import '../data/catalog_discovery.dart';
import '../data/content_policy.dart';
import '../data/models.dart';
import '../data/home_banners.dart';
import '../media/player_page.dart';
import '../media/media_platform.dart';
import '../media/download_button.dart';
import '../state/app_state.dart';
import 'common.dart';
import 'home_banners.dart';
part 'catalog_extras.dart';
void _push(BuildContext context, Widget page) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
List<TitleRecord> _titles(dynamic value) => value is List
? value
.whereType<Map>()
.where(
(row) =>
ContentPolicy.allowsTitleJson(Map<String, dynamic>.from(row)),
)
.map((e) => TitleRecord.fromJson(Map<String, dynamic>.from(e)))
.where((e) => e.id > 0 && !e.isChannel)
.toList()
: [];
List<Map<String, dynamic>> _maps(dynamic value) => value is List
? value.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: [];
String _text(Map<String, dynamic> item, List<String> keys) {
for (final key in keys) {
if (item[key] != null && '${item[key]}'.isNotEmpty) return '${item[key]}';
}
return '';
}
int _id(Map<String, dynamic> item, List<String> keys) =>
int.tryParse(_text(item, keys)) ?? 0;
class DiscoverPage extends StatefulWidget {
const DiscoverPage({super.key});
@override
State<DiscoverPage> createState() => _DiscoverPageState();
}
class _DiscoverPageState extends State<DiscoverPage> {
Future<Map<String, dynamic>>? _home;
Future<List<HomeBanner>>? _banners;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_home ??= AppScope.read(context).api.home();
_banners ??= HomeBannerService(AppScope.read(context).api).load();
}
void _retry() => setState(() {
_home = AppScope.read(context).api.home();
_banners = HomeBannerService(AppScope.read(context).api).load();
});
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
_retry();
try {
await _home;
} catch (_) {}
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(22, 18, 12, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'REELBOX',
style: TextStyle(
letterSpacing: 4,
fontSize: 12,
fontWeight: FontWeight.w800,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 6),
Text(
tr(
context,
'Find your next story.',
'داستان بعدی‌ات را پیدا کن.',
),
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
],
),
),
IconButton(
onPressed: () =>
_push(context, const DiscoveryDirectoryPage()),
tooltip: tr(context, 'Explore more', 'کشف بیشتر'),
icon: const Icon(Icons.explore_outlined),
),
IconButton(
onPressed: () => _push(context, const SearchPage()),
tooltip: tr(context, 'Search', 'جستجو'),
icon: const Icon(Icons.search_rounded),
),
],
),
),
HomeBanners(future: _banners!),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
spacing: 8,
children: [
_shortcut(
context,
'Movies',
'فیلم‌ها',
Icons.movie_outlined,
const BrowsePage(),
),
_shortcut(
context,
'Series',
'سریال‌ها',
Icons.video_library_outlined,
const BrowsePage(initialKind: 'serie'),
),
_shortcut(
context,
'Anime & animation',
'انیمه و انیمیشن',
Icons.auto_awesome_outlined,
const BrowsePage(initialKind: 'mixed', initialGenre: 3),
),
_shortcut(
context,
'Actors',
'بازیگران',
Icons.people_outline,
const ActorsPage(),
),
_shortcut(
context,
'Countries',
'کشورها',
Icons.public,
const CountriesPage(),
),
],
),
),
AsyncResult<Map<String, dynamic>>(
future: _home!,
onRetry: _retry,
builder: (data) {
final rails = _maps(data['genres'])
.where(
(row) => ContentPolicy.allowsText(_text(row, ['title'])),
)
.toList();
final featured = [
..._titles(data['posters']),
...rails.expand((g) => _titles(g['posters'])),
];
final slides = _maps(data['slides'])
.where(
(slide) =>
ContentPolicy.allowsText(
_text(slide, ['title', 'name']),
) &&
ContentPolicy.allowsUrl(_text(slide, ['url'])) &&
ContentPolicy.allowsUrl(_text(slide, ['image'])),
)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (featured.isNotEmpty) _CinemaHero(title: featured.first),
if (slides.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: SizedBox(
height: 100,
child: PageView(
children: [
for (final slide in slides)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => _openWeb(
context,
_text(slide, ['url']),
),
child: Stack(
fit: StackFit.expand,
children: [
MediaArtwork(
url: _text(slide, ['image']),
),
const PositionedDirectional(
end: 10,
bottom: 10,
child: Icon(
Icons.open_in_new_rounded,
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
for (final rail in rails)
if (_titles(rail['posters']).isNotEmpty)
PosterRail(
title: _text(rail, ['title']),
items: _titles(rail['posters']),
onMore: _id(rail, ['id']) > 0
? () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialGenre: _id(rail, ['id']),
heading: _text(rail, ['title']),
),
)
: null,
),
if (featured.isEmpty)
EmptyView(
title: tr(
context,
'No discoveries yet',
'هنوز محتوایی وجود ندارد',
),
onRetry: _retry,
),
],
);
},
),
const _DiscoveryHomeShelves(),
const _ContinueWatchingShelf(),
for (final genre in AppScope.of(
context,
).prefs.preferredGenres.take(3))
_RecommendationRail(
key: ValueKey('preference:$genre'),
genre: genre,
),
const SizedBox(height: 18),
],
),
),
),
);
Widget _shortcut(
BuildContext context,
String en,
String fa,
IconData icon,
Widget page,
) => ActionChip(
avatar: Icon(icon, size: 17),
label: Text(tr(context, en, fa)),
onPressed: () => _push(context, page),
);
}
class _CinemaHero extends StatelessWidget {
const _CinemaHero({required this.title});
final TitleRecord title;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: SizedBox(
height: MediaQuery.sizeOf(context).width > 700 ? 400 : 345,
child: Stack(
fit: StackFit.expand,
children: [
MediaArtwork(
url: title.cover.isNotEmpty ? title.cover : title.image,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x18000000), Color(0xF5080A0F)],
stops: [.1, 1],
),
),
),
PositionedDirectional(
start: 22,
end: 22,
bottom: 24,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(context, 'IN THE SPOTLIGHT', 'در کانون توجه'),
style: const TextStyle(
color: Color(0xFFFFA298),
letterSpacing: 2,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
title.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
height: 1.05,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 10),
Text(
[
if (title.year > 0) '${title.year}',
if (title.imdb > 0) '★ ${title.imdb.toStringAsFixed(1)}',
...title.genres.take(2).map((g) => g.title),
].join(' · '),
style: const TextStyle(
color: Color(0xFFDDDEE3),
fontSize: 12,
),
),
const SizedBox(height: 18),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () => goTitle(context, title),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
tr(context, 'Explore title', 'مشاهده عنوان'),
),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
),
onPressed: () => _save(context, title),
icon: Icon(
AppScope.of(context)
.hasSaved(title.id, kind: title.type)
? Icons.check
: Icons.add,
),
label: Text(tr(context, 'Watchlist', 'تماشای بعدی')),
),
],
),
],
),
),
],
),
),
),
);
}
Future<void> _save(BuildContext context, TitleRecord title) async {
final state = AppScope.read(context);
try {
await state.toggleSaved(title);
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not save locally. Please retry.',
'ذخیره محلی ممکن نشد. دوباره تلاش کنید.',
),
);
}
}
}
Future<void> _openWeb(BuildContext context, String value) async {
final uri = Uri.tryParse(value);
if (!ContentPolicy.allowsUrl(value) ||
uri == null ||
!{'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty) {
toast(
context,
tr(context, 'This link is unavailable.', 'این پیوند در دسترس نیست.'),
);
return;
}
try {
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
throw StateError('Unavailable');
}
} catch (_) {
if (context.mounted) {
toast(
context,
tr(context, 'Could not open this link.', 'باز کردن پیوند ممکن نشد.'),
);
}
}
}
/// Paged requests retain successful pages and the raw server cursor.
class _PagedTitles extends StatefulWidget {
const _PagedTitles({
super.key,
required this.load,
this.firstPage = 1,
this.headerBuilder,
}) : header = null;
final Future<List<TitleRecord>> Function(int page) load;
final int firstPage;
final Widget? header;
final Widget Function()? headerBuilder;
@override
State<_PagedTitles> createState() => _PagedTitlesState();
}
class _PagedTitlesState extends State<_PagedTitles> {
final _items = <TitleRecord>[];
final _scroll = ScrollController();
bool _busy = false, _end = false, _failed = false;
late int _page;
int _emptyPages = 0;
@override
void initState() {
super.initState();
_page = widget.firstPage;
_scroll.addListener(_nearEnd);
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _nearEnd() {
if (!_failed &&
_emptyPages < 4 &&
_scroll.hasClients &&
_scroll.position.extentAfter < 450) {
_load(automatic: true);
}
}
void _fillViewport() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_busy && !_end && !_failed) _nearEnd();
});
}
Future<void> _load({bool automatic = false}) async {
if (_busy || _end || !mounted) return;
if (!automatic) _emptyPages = 0;
setState(() {
_busy = true;
_failed = false;
});
try {
final next = await widget.load(_page);
if (!mounted) return;
final known = _items.map((e) => e.libraryKey).toSet();
final fresh = next
.where(
(e) =>
e.id > 0 &&
e.title.isNotEmpty &&
!e.isChannel &&
ContentPolicy.allowsTitleJson(e.toJson()) &&
known.add(e.libraryKey),
)
.toList();
setState(() {
_items.addAll(fresh);
_emptyPages = fresh.isEmpty ? _emptyPages + 1 : 0;
// Never derive exhaustion/cursor from locally visible or deduped rows.
_end = next is CatalogPage ? next.nextPage == null : next.isEmpty;
_page = next is CatalogPage ? next.nextPage ?? _page : _page + 1;
});
} catch (_) {
if (mounted) setState(() => _failed = true);
} finally {
if (mounted) {
setState(() => _busy = false);
_fillViewport();
}
}
}
Future<void> _refresh() async {
if (_busy) return;
setState(() {
_items.clear();
_end = false;
_failed = false;
_emptyPages = 0;
_page = widget.firstPage;
});
await _load();
}
@override
Widget build(BuildContext context) {
final compact = AppScope.of(context).prefs.compactCatalog;
return RefreshIndicator(
onRefresh: _refresh,
child: CustomScrollView(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(child: Column(children: [
if (widget.headerBuilder != null) widget.headerBuilder!(),
if (widget.header != null) widget.header!,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
'${_items.length} ${tr(context, 'titles loaded', 'عنوان بارگذاری شده')}',
),
),
IconButton(
tooltip: compact
? tr(context, 'Poster grid', 'نمای پوستر')
: tr(context, 'Compact rows', 'ردیف‌های فشرده'),
icon: Icon(compact ? Icons.grid_view : Icons.view_list),
onPressed: () async {
try {
final state = AppScope.read(context);
await state.updateSettings(
state.prefs.copyWith(compactCatalog: !compact),
);
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not save preference',
'ذخیره تنظیم ممکن نشد',
),
);
}
}
},
),
],
),
),
])),
if (_items.isNotEmpty) PosterGrid(items: _items, sliver: true),
SliverToBoxAdapter(child: Column(children: [
if (_busy)
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
if (_failed)
EmptyView(
title: tr(
context,
'Could not load titles',
'بارگذاری عنوان‌ها ممکن نشد',
),
message: tr(
context,
'Check your connection or try again later.',
'اتصال را بررسی کنید یا بعداً دوباره تلاش کنید.',
),
icon: Icons.wifi_off_rounded,
onRetry: _load,
),
if (!_busy && !_failed && _end && _items.isEmpty)
EmptyView(
title: tr(context, 'No titles found', 'عنوانی پیدا نشد'),
message: tr(
context,
'Try a different search or filter.',
'جستجو یا فیلتر دیگری را امتحان کنید.',
),
onRetry: _refresh,
),
if (!_busy && !_failed && !_end)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.expand_more),
label: Text(tr(context, 'Load more', 'نمایش بیشتر')),
),
),
if (_end && _items.isNotEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
tr(
context,
'You’re all caught up',
'همه موارد نمایش داده شد',
),
style: Theme.of(context).textTheme.bodySmall,
),
),
),
const SizedBox(height: 16),
])),
],
),
);
}
}
class BrowsePage extends StatefulWidget {
const BrowsePage({
super.key,
this.initialKind = 'movie',
this.initialGenre = 0,
this.initialCountry = 0,
this.initialOrder = 'created',
this.heading,
});
final String initialKind, initialOrder;
final int initialGenre, initialCountry;
final String? heading;
@override
State<BrowsePage> createState() => _BrowsePageState();
}
class _BrowsePageState extends State<BrowsePage> {
late String _kind;
late int _genre, _country;
String _order = 'created';
Future<List<List<Tag>>>? _filters;
int _apiRevision = -1;
@override
void initState() {
super.initState();
_kind = {'movie', 'serie', 'mixed'}.contains(widget.initialKind)
? widget.initialKind
: 'movie';
_genre = widget.initialGenre;
_country = widget.initialCountry;
_order = widget.initialOrder;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final state = AppScope.of(context);
if (_apiRevision != state.apiRevision) {
_apiRevision = state.apiRevision;
if (state.usesIranCatalogue && _order == 'views') _order = 'created';
_filters = _loadFilters();
}
}
Future<List<List<Tag>>> _loadFilters() async {
final api = AppScope.read(context).api;
return Future.wait([api.genres(), api.countries()]);
}
Future<List<TitleRecord>> _load(int page) {
final api = AppScope.read(context).api;
return api.catalog(
kind: _kind,
genre: _genre,
country: _country,
order: _order,
page: page,
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.heading ?? tr(context, 'Browse', 'مرور')),
actions: [
IconButton(
onPressed: () => _push(context, const SearchPage()),
icon: const Icon(Icons.search),
tooltip: tr(context, 'Search', 'جستجو'),
),
],
),
body: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(18, 4, 18, 12),
child: Row(
spacing: 8,
children: [
for (final option in [
('movie', 'Movies', 'فیلم‌ها'),
('serie', 'Series', 'سریال‌ها'),
('mixed', 'All titles', 'همه عنوان‌ها'),
('anime', 'Anime & animation', 'انیمه و انیمیشن'),
])
ChoiceChip(
label: Text(tr(context, option.$2, option.$3)),
selected: option.$1 == 'anime'
? _kind == 'mixed' && _genre == 3
: _kind == option.$1 &&
!(option.$1 == 'mixed' && _genre == 3),
onSelected: (_) => setState(() {
_kind = option.$1 == 'anime' ? 'mixed' : option.$1;
_genre = option.$1 == 'anime' ? 3 : 0;
}),
),
],
),
),
FutureBuilder<List<List<Tag>>>(
future: _filters,
builder: (context, snapshot) {
if (snapshot.hasError) {
return TextButton.icon(
onPressed: () => setState(() {
_filters = _loadFilters();
}),
icon: const Icon(Icons.refresh),
label: Text(
tr(
context,
'Retry loading filters',
'تلاش دوباره برای فیلترها',
),
),
);
}
if (!snapshot.hasData) return const LinearProgressIndicator();
final genres = snapshot.data![0];
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Wrap(
spacing: 10,
runSpacing: 8,
children: [
_filterMenu(
context,
tr(context, 'Genre', 'ژانر'),
_genre,
genres,
(value) => setState(() => _genre = value),
),
_filterMenu(
context,
tr(context, 'Country', 'کشور'),
_country,
snapshot.data![1],
(value) => setState(() => _country = value),
),
PopupMenuButton<String>(
tooltip: tr(context, 'Sort titles', 'مرتب‌سازی'),
initialValue: _order,
onSelected: (value) => setState(() => _order = value),
itemBuilder: (_) => [
for (final option in [
('created', 'Recently added', 'تازه اضافه‌شده'),
('rating', 'Audience rating', 'امتیاز کاربران'),
if (!AppScope.of(context).usesIranCatalogue)
('views', 'Most viewed', 'پربازدیدترین'),
('imdb', 'IMDb rating', 'امتیاز IMDb'),
('year', 'Release year', 'سال انتشار'),
('title', 'Title', 'عنوان'),
])
PopupMenuItem(
value: option.$1,
child: Text(tr(context, option.$2, option.$3)),
),
],
child: Chip(
avatar: const Icon(Icons.sort, size: 18),
label: Text(_sortLabel(context, _order)),
),
),
if (_genre != 0 || _country != 0 || _order != 'created')
ActionChip(
label: Text(tr(context, 'Reset', 'بازنشانی')),
avatar: const Icon(Icons.clear, size: 16),
onPressed: () => setState(() {
_genre = 0;
_country = 0;
_order = 'created';
}),
),
],
),
);
},
),
Expanded(
child: _PagedTitles(
key: ValueKey('$_apiRevision:$_kind:$_genre:$_country:$_order'),
load: _load,
firstPage: 0,
),
),
],
),
);
}
String _sortLabel(BuildContext context, String order) => switch (order) {
'rating' => tr(context, 'Audience rating', 'امتیاز کاربران'),
'views' => tr(context, 'Most viewed', 'پربازدیدترین'),
'imdb' => tr(context, 'IMDb rating', 'امتیاز IMDb'),
'year' => tr(context, 'Release year', 'سال انتشار'),
'title' => tr(context, 'Title', 'عنوان'),
_ => tr(context, 'Recently added', 'تازه اضافه‌شده'),
};
Widget _filterMenu(
BuildContext context,
String label,
int current,
List<Tag> options,
ValueChanged<int> changed,
) => PopupMenuButton<int>(
tooltip: label,
initialValue: current,
onSelected: changed,
itemBuilder: (_) => [
PopupMenuItem(value: 0, child: Text(tr(context, 'All', 'همه'))),
for (final tag in options)
if (tag.id > 0 && ContentPolicy.allowsText(tag.title))
PopupMenuItem(value: tag.id, child: Text(tag.title)),
],
child: Chip(
avatar: const Icon(Icons.tune_rounded, size: 17),
label: Text(
options.where((e) => e.id == current).firstOrNull?.title ??
(current == 0 ? label : '$label: $current'),
),
),
);
class SearchPage extends StatefulWidget {
const SearchPage({super.key, this.initialQuery = ''});
final String initialQuery;
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _input = TextEditingController();
Future<List<String>>? _trending;
Future<CatalogPage>? _suggestions;
Future<List<List<Tag>>>? _filterOptions;
String _query = '';
bool _english = false, _help = false;
String _kind = 'mixed';
int _year = 0, _genre = 0, _country = 0;
double _rating = 0;
DiscoverySearchFilter get _filter => DiscoverySearchFilter(
kind: _kind,
minimumYear: _year,
minimumRating: _rating,
genre: _genre,
country: _country,
);
@override
void initState() {
super.initState();
_query = widget.initialQuery.trim();
_input.text = _query;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final api = AppScope.read(context).api;
_trending ??= api.trending();
_suggestions ??= CatalogDiscovery(api).suggestions();
}
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _submit(String value) {
final query = value.trim();
_input.text = query;
setState(() => _query = query);
if (query.isNotEmpty && ContentPolicy.allowsText(query)) {
AppScope.read(context).rememberSearch(query).catchError((Object _) {});
}
FocusScope.of(context).unfocus();
}
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return Scaffold(
appBar: AppBar(
title: Text(tr(context, 'Search', 'جستجو')),
actions: [
IconButton(
tooltip: tr(context, 'Search help', 'راهنمای جستجو'),
icon: const Icon(Icons.help_outline),
onPressed: () => setState(() => _help = !_help),
),
],
),
body: LayoutBuilder(
builder: (context, constraints) => Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * .65,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: TextField(
controller: _input,
textInputAction: TextInputAction.search,
onSubmitted: _submit,
decoration: InputDecoration(
hintText: tr(
context,
'Titles in Persian or English',
'عنوان به فارسی یا انگلیسی',
),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: IconButton(
onPressed: () => _submit(''),
tooltip: tr(
context,
'Clear search',
'پاک کردن جستجو',
),
icon: const Icon(Icons.close),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
),
),
),
),
if (_help)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
tr(
context,
'Search in Persian or use English search for the original title. Advanced filters apply to loaded search results, not a separate server query. Counts show loaded matching titles, not the server total.',
'به فارسی جستجو کنید یا برای عنوان اصلی جستجوی انگلیسی را انتخاب کنید. فیلترهای پیشرفته روی نتایج بارگذاری‌شده اعمال می‌شوند. شمارش، تعداد نتایج بارگذاری‌شده است نه کل سرویس.',
),
),
),
ExpansionTile(
title: Text(
tr(context, 'Advanced filters', 'فیلترهای پیشرفته'),
),
onExpansionChanged: (expanded) {
if (expanded && _filterOptions == null) {
setState(() {
_filterOptions = Future.wait([
state.api.genres(),
state.api.countries(),
]);
});
}
},
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
FilterChip(
selected: _english,
label: Text(
tr(
context,
'English search',
'جستجوی انگلیسی',
),
),
onSelected: (value) =>
setState(() => _english = value),
),
for (final option in [
('mixed', 'All titles', 'همه عنوان‌ها'),
('movie', 'Movies', 'فیلم‌ها'),
('serie', 'Series', 'سریال‌ها'),
])
ChoiceChip(
label: Text(
tr(context, option.$2, option.$3),
),
selected: _kind == option.$1,
onSelected: (_) =>
setState(() => _kind = option.$1),
),
PopupMenuButton<int>(
tooltip: tr(
context,
'Minimum year',
'حداقل سال',
),
onSelected: (value) =>
setState(() => _year = value),
itemBuilder: (_) => [
for (final value in [
0,
2020,
2010,
2000,
1990,
])
PopupMenuItem(
value: value,
child: Text(
value == 0
? tr(
context,
'Any year',
'همه سال‌ها',
)
: '$value+',
),
),
],
child: Chip(
label: Text(
_year == 0
? tr(
context,
'Minimum year',
'حداقل سال',
)
: '$_year+',
),
),
),
PopupMenuButton<double>(
tooltip: tr(
context,
'Minimum IMDb',
'حداقل IMDb',
),
onSelected: (value) =>
setState(() => _rating = value),
itemBuilder: (_) => [
for (final value in [0.0, 5.0, 6.0, 7.0, 8.0])
PopupMenuItem(
value: value,
child: Text(
value == 0
? tr(
context,
'Any rating',
'همه امتیازها',
)
: '$value+',
),
),
],
child: Chip(
label: Text(
_rating == 0
? tr(
context,
'Minimum IMDb',
'حداقل IMDb',
)
: 'IMDb $_rating+',
),
),
),
ActionChip(
label: Text(
tr(
context,
'Reset filters',
'بازنشانی فیلترها',
),
),
onPressed: () => setState(() {
_english = false;
_kind = 'mixed';
_year = _genre = _country = 0;
_rating = 0;
}),
),
],
),
),
if (_filterOptions != null)
FutureBuilder<List<List<Tag>>>(
future: _filterOptions,
builder: (context, snapshot) {
if (snapshot.hasError) {
return TextButton(
onPressed: () => setState(() {
_filterOptions = Future.wait([
state.api.genres(),
state.api.countries(),
]);
}),
child: Text(
tr(
context,
'Retry loading filters',
'تلاش دوباره برای فیلترها',
),
),
);
}
if (!snapshot.hasData) {
return const LinearProgressIndicator();
}
return Wrap(
spacing: 8,
children: [
_filterMenu(
context,
tr(context, 'Genre', 'ژانر'),
_genre,
snapshot.data![0],
(value) => setState(() => _genre = value),
),
_filterMenu(
context,
tr(context, 'Country', 'کشور'),
_country,
snapshot.data![1],
(value) => setState(() => _country = value),
),
],
);
},
),
],
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: IconButton.filledTonal(
onPressed: () => _submit(_input.text),
tooltip: tr(context, 'Search', 'جستجو'),
icon: const Icon(Icons.arrow_forward_rounded),
),
),
),
],
),
),
),
Expanded(
child: _query.isNotEmpty
? _PagedTitles(
key: ValueKey('$_query:$_english:${_filter.key}'),
load: (page) => CatalogDiscovery(state.api).search(
_query,
page: page,
english: _english,
filter: _filter,
),
)
: ListView(
children: [
if (state.searchHistory.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Expanded(
child: _heading(
context,
tr(
context,
'Recent searches',
'جستجوهای اخیر',
),
),
),
TextButton(
onPressed: () async {
try {
await state.clearSearchHistory();
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not clear history',
'پاک کردن تاریخچه ممکن نشد',
),
);
}
}
},
child: Text(tr(context, 'Clear', 'پاک کردن')),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
children: [
for (final query in state.searchHistory.where(
ContentPolicy.allowsText,
))
InputChip(
label: Text(query),
avatar: const Icon(Icons.history, size: 16),
onPressed: () => _submit(query),
deleteButtonTooltipMessage:
'${tr(context, 'Remove', 'حذف')} $query',
onDeleted: () async {
try {
await state.removeSearch(query);
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not remove search',
'حذف جستجو ممکن نشد',
),
);
}
}
},
),
],
),
),
],
Padding(
padding: const EdgeInsets.all(20),
child: _heading(
context,
tr(
context,
'Suggested titles',
'عنوان‌های پیشنهادی',
),
),
),
AsyncResult<CatalogPage>(
future: _suggestions!,
onRetry: () => setState(() {
_suggestions = CatalogDiscovery(state.api)
.suggestions();
}),
builder: (titles) => titles.isEmpty
? EmptyView(
title: tr(
context,
'No suggestions available',
'پیشنهادی موجود نیست',
),
)
: PosterGrid(items: titles.take(12).toList()),
),
TextButton.icon(
onPressed: () => _push(
context,
const RecommendationsPage(byYear: true),
),
icon: const Icon(Icons.shuffle),
label: Text(
tr(context, 'Best by year', 'بهترین‌های هر سال'),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: _heading(
context,
tr(
context,
'Trending searches',
'جستجوهای پرطرفدار',
),
),
),
AsyncResult<List<String>>(
future: _trending!,
onRetry: () => setState(() {
_trending = state.api.trending();
}),
builder: (terms) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final term in terms.where(
ContentPolicy.allowsText,
))
ActionChip(
label: Text(term),
avatar: const Icon(
Icons.trending_up,
size: 16,
),
onPressed: () => _submit(term),
),
],
),
),
),
],
),
),
],
),
),
);
}
}
Widget _heading(BuildContext context, String text) => Text(
text,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
);
class TitlePage extends StatefulWidget {
const TitlePage({super.key, required this.title});
final TitleRecord title;
@override
State<TitlePage> createState() => _TitlePageState();
}
class _TitlePageState extends State<TitlePage> {
Future<TitleRecord>? _detail;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_detail ??= _fetch();
}
Future<TitleRecord> _fetch() {
final request = _fetchTitle();
// Retry may fail before the next frame attaches FutureBuilder's listener.
// Mark that error handled without changing the future the UI observes.
request.ignore();
return request;
}
Future<TitleRecord> _fetchTitle() async {
if (widget.title.isChannel ||
!ContentPolicy.allowsTitleJson(widget.title.toJson())) {
throw const ApiException('This title is unavailable.');
}
final title = await AppScope.read(context).api.poster(widget.title.id);
if (title.id <= 0 ||
title.isChannel ||
!ContentPolicy.allowsTitleJson(title.toJson())) {
throw const ApiException('This title is unavailable.');
}
return title;
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
ContentPolicy.allowsTitleJson(widget.title.toJson()) &&
!widget.title.isChannel
? widget.title.title
: tr(context, 'Unavailable title', 'عنوان در دسترس نیست'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
body: SingleChildScrollView(
child: AsyncResult<TitleRecord>(
future: _detail!,
onRetry: () => setState(() {
_detail = _fetch();
}),
builder: (title) => _TitleBody(
key: ValueKey('${title.type}:${title.id}'),
title: title,
),
),
),
);
}
class _TitleBody extends StatelessWidget {
const _TitleBody({super.key, required this.title});
final TitleRecord title;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final api = state.api;
final genres = title.genres;
final website = _text(title.raw, ['website']);
final related = _titles(title.raw['related'] ?? title.raw['posters']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: MediaQuery.sizeOf(context).width > 700 ? 360 : 270,
child: Stack(
fit: StackFit.expand,
children: [
MediaArtwork(
url: title.cover.isNotEmpty ? title.cover : title.image,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Theme.of(context).scaffoldBackgroundColor,
],
),
),
),
PositionedDirectional(
start: 24,
end: 24,
bottom: 12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: MediaArtwork(
url: title.image,
width: 100,
height: 148,
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.isSeries
? tr(context, 'SERIES', 'سریال')
: tr(context, 'MOVIE', 'فیلم'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
letterSpacing: 2,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
title.title,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w900),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
[
if (title.year > 0) '${title.year}',
if (title.duration.isNotEmpty) title.duration,
if (title.classification.isNotEmpty)
title.classification,
].join(' · '),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(22, 12, 22, 4),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (title.imdb > 0)
_badge(context, 'IMDb ★ ${title.imdb.toStringAsFixed(1)}'),
if (title.rating > 0)
_badge(
context,
'${tr(context, 'Audience', 'کاربران')} ${title.rating.toStringAsFixed(1)}',
),
for (final country in title.countries)
ActionChip(
label: Text(country.title),
onPressed: () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialCountry: country.id,
heading: country.title,
),
),
),
for (final genre in genres)
ActionChip(
label: Text(genre.title),
onPressed: () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialGenre: genre.id,
heading: genre.title,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(22, 12, 22, 8),
child: Wrap(
spacing: 10,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: () => _save(context, title),
icon: Icon(
state.hasSaved(title.id, kind: title.type)
? Icons.bookmark
: Icons.bookmark_add_outlined,
),
label: Text(
state.hasSaved(title.id, kind: title.type)
? tr(context, 'In watchlist', 'در تماشای بعدی')
: tr(
context,
'Add to watchlist',
'افزودن به تماشای بعدی',
),
),
),
OutlinedButton.icon(
onPressed: () => _shareTitle(context, title),
icon: const Icon(Icons.share_outlined),
label: Text(tr(context, 'Share', 'اشتراک‌گذاری')),
),
if (website.isNotEmpty && ContentPolicy.allowsUrl(website))
OutlinedButton.icon(
onPressed: () => _openWeb(context, website),
icon: const Icon(Icons.language),
label: Text(tr(context, 'Website', 'وب‌سایت')),
),
],
),
),
if (title.description.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(22, 16, 22, 8),
child: _Synopsis(text: title.description),
),
if (title.isSeries)
_RemotePanel<SeasonRecord>(
title: tr(context, 'Seasons & episodes', 'فصل‌ها و قسمت‌ها'),
initiallyExpanded: true,
load: () => api.seasons(title.id),
builder: (seasons) {
final playlist = seasons.expand((s) => s.episodes).toList();
return Column(
children: [
for (final season in seasons)
ExpansionTile(
title: Text(
season.title.isEmpty
? '${tr(context, 'Season', 'فصل')} ${season.id}'
: season.title,
),
subtitle: Text(
'${season.episodes.length} ${tr(context, 'episodes', 'قسمت')}',
),
children: [
if (season.episodes.isEmpty)
EmptyView(
title: tr(
context,
'No episodes available',
'قسمتی موجود نیست',
),
),
for (final episode in season.episodes)
_EpisodeTile(
title: title,
episode: episode,
playlist: playlist,
),
],
),
],
);
},
),
if (!title.isSeries || title.sources.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(22, 18, 22, 8),
child: _heading(context, tr(context, 'Sources', 'منابع پخش')),
),
if (!title.isSeries || title.sources.isNotEmpty)
_SourceList(title: title, sources: title.sources),
_RemotePanel<Map<String, dynamic>>(
title: tr(context, 'Cast & crew', 'بازیگران و عوامل'),
load: () => api.roles(title.id),
builder: (actors) => _PeopleGrid(items: actors),
),
if (!title.isSeries)
_RemotePanel<Map<String, dynamic>>(
title: tr(context, 'Subtitles', 'زیرنویس‌ها'),
load: () => api.subtitles('movie', title.id),
builder: (items) => Column(
children: [for (final item in items) _SubtitleTile(item: item)],
),
),
if (related.isNotEmpty)
PosterRail(
title: tr(context, 'Related titles', 'عنوان‌های مرتبط'),
items: related,
),
if (title.genres.isNotEmpty)
_RecommendationRail(
genre: title.genres.first.id,
excludeId: title.id,
),
const SizedBox(height: 32),
],
);
}
}
Widget _badge(BuildContext context, String text) => DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Text(
text,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
),
),
);
class _Synopsis extends StatefulWidget {
const _Synopsis({required this.text});
final String text;
@override
State<_Synopsis> createState() => _SynopsisState();
}
class _SynopsisState extends State<_Synopsis> {
bool _expanded = false;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_heading(context, tr(context, 'Story', 'داستان')),
const SizedBox(height: 10),
SelectableText(
widget.text,
maxLines: _expanded ? null : 6,
style: TextStyle(
height: 1.7,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (widget.text.length > 220)
TextButton(
onPressed: () => setState(() => _expanded = !_expanded),
child: Text(
_expanded
? tr(context, 'Show less', 'نمایش کمتر')
: tr(context, 'Read more', 'بیشتر بخوانید'),
),
),
],
);
}
class _EpisodeTile extends StatelessWidget {
const _EpisodeTile({
required this.title,
required this.episode,
required this.playlist,
});
final TitleRecord title;
final EpisodeRecord episode;
final List<EpisodeRecord> playlist;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final watched = state.history
.where(
(entry) =>
entry.title.libraryKey == title.libraryKey &&
entry.episodeId == episode.id,
)
.firstOrNull;
return ExpansionTile(
key: ValueKey('episode:${episode.id}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: state.isEpisodeWatched(title.id, episode.id)
? tr(context, 'Mark unwatched', 'علامت ناتماشاشده')
: tr(context, 'Mark watched', 'علامت تماشاشده'),
icon: Icon(
state.isEpisodeWatched(title.id, episode.id)
? Icons.check_circle
: Icons.radio_button_unchecked,
),
onPressed: () async {
try {
await state.setEpisodeWatched(
title,
episode,
!state.isEpisodeWatched(title.id, episode.id),
);
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not save watched status',
'ذخیره وضعیت تماشا ممکن نشد',
),
);
}
}
},
),
const Icon(Icons.expand_more),
],
),
leading: SizedBox(
width: 52,
height: 42,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: MediaArtwork(url: _text(episode.raw, ['image'])),
),
),
title: Text(
episode.title.isEmpty
? '${tr(context, 'Episode', 'قسمت')} ${episode.id}'
: episode.title,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.isEpisodeWatched(title.id, episode.id)
? tr(context, 'Watched', 'تماشاشده')
: tr(context, 'Unwatched', 'تماشانشده'),
),
if (episode.duration.isNotEmpty) Text(episode.duration),
if (watched != null) ...[
const SizedBox(height: 5),
LinearProgressIndicator(value: watched.progress),
const SizedBox(height: 3),
Text(
'${(watched.progress * 100).round()}% ${tr(context, 'watched', 'تماشاشده')}',
style: Theme.of(context).textTheme.labelSmall,
),
],
],
),
children: [
if (episode.description.isNotEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(episode.description),
),
_SourceList(
title: title,
sources: episode.sources,
episode: episode,
playlist: playlist,
),
_RemotePanel<Map<String, dynamic>>(
title: tr(context, 'Episode subtitles', 'زیرنویس قسمت'),
load: () => state.api.subtitles('episode', episode.id),
builder: (items) => Column(
children: [for (final item in items) _SubtitleTile(item: item)],
),
),
],
);
}
}
class _SourceList extends StatelessWidget {
const _SourceList({
required this.title,
required this.sources,
this.episode,
this.playlist = const [],
});
final TitleRecord title;
final List<MediaSource> sources;
final EpisodeRecord? episode;
final List<EpisodeRecord> playlist;
@override
Widget build(BuildContext context) {
final visible = sources
.where(
(s) =>
ContentPolicy.allowsUrl(s.url) &&
ContentPolicy.allowsText(s.quality) &&
!(AppScope.of(context).prefs.hideTrailers && s.isTrailer),
)
.toList();
if (visible.isEmpty) {
return EmptyView(
title: tr(
context,
'No public sources available',
'منبع عمومی موجود نیست',
),
message: tr(
context,
'The service has not returned a playable source for this title.',
'سرویس برای این عنوان منبع پخش برنگردانده است.',
),
);
}
return Column(
children: [
if (visible.any((s) => !s.isTrailer && s.url.isNotEmpty))
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
key: const ValueKey('play-preferred'),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
tr(context, 'Play preferred source', 'پخش منبع ترجیحی'),
),
onPressed: () async {
final quality = AppScope.read(context).prefs.defaultQuality;
final playable = visible
.where((s) => !s.isTrailer && s.url.isNotEmpty)
.toList();
var preferred =
playable
.where(
(s) => RegExp(
r'(^|\D)' + RegExp.escape(quality) + r'(p\b|\D|$)',
caseSensitive: false,
).hasMatch(s.quality),
)
.firstOrNull ??
playable.first;
if (quality == 'ask') {
final selected = await showDialog<MediaSource>(
context: context,
builder: (context) => SimpleDialog(
title: Text(
tr(context, 'Choose a source', 'انتخاب منبع'),
),
children: [
for (final option in playable)
SimpleDialogOption(
onPressed: () => Navigator.pop(context, option),
child: Text(
option.quality.isEmpty
? tr(context, 'Original source', 'منبع اصلی')
: option.quality,
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(tr(context, 'Cancel', 'لغو')),
),
],
),
);
if (selected == null || !context.mounted) return;
preferred = selected;
}
if (!context.mounted) return;
await _play(
context,
title,
preferred,
episode: episode,
playlist: playlist,
alternatives: visible,
);
},
),
),
for (final source in visible)
Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 6),
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
source.isTrailer
? Icons.local_play_outlined
: Icons.high_quality_outlined,
size: 21,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
source.quality.isNotEmpty
? source.quality
: tr(context, 'Original source', 'منبع اصلی'),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
if (source.type.isNotEmpty)
_badge(context, source.type.toUpperCase()),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 2,
runSpacing: 2,
children: [
IconButton.filledTonal(
onPressed: source.url.isEmpty
? null
: () => _play(
context,
title,
source,
episode: episode,
playlist: playlist,
alternatives: visible,
),
tooltip: tr(context, 'Play', 'پخش'),
icon: const Icon(Icons.play_arrow_rounded),
),
DownloadButton(
title: title,
source: source,
episode: episode,
),
IconButton(
onPressed: source.url.isEmpty
? null
: () => _copy(context, source.url),
tooltip: tr(
context,
'Copy source URL',
'کپی پیوند منبع',
),
icon: const Icon(Icons.copy_all_outlined),
),
IconButton(
onPressed: source.url.isEmpty
? null
: () => _share(context, source.url, title.title),
tooltip: tr(context, 'Share source', 'اشتراک منبع'),
icon: const Icon(Icons.share_outlined),
),
TextButton.icon(
onPressed: source.url.isEmpty
? null
: () => _play(
context,
title,
source,
episode: episode,
playlist: playlist,
alternatives: visible,
external: true,
),
label: Text(
tr(context, 'External player', 'پخش‌کننده خارجی'),
),
icon: const Icon(Icons.open_in_new_rounded),
),
],
),
],
),
),
),
),
],
);
}
}
Future<void> _play(
BuildContext context,
TitleRecord title,
MediaSource source, {
EpisodeRecord? episode,
List<EpisodeRecord> playlist = const [],
List<MediaSource> alternatives = const [],
bool external = false,
}) async {
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
!ContentPolicy.allowsUrl(source.url)) {
toast(
context,
tr(context, 'This source is unavailable.', 'این منبع در دسترس نیست.'),
);
return;
}
final type = source.type.toLowerCase();
final uri = Uri.tryParse(source.url);
if (type.contains('youtube') ||
type.contains('embed') ||
uri?.host == 'youtu.be' ||
(uri?.host.endsWith('youtube.com') ?? false)) {
try {
if (MediaPlatform.supported) {
if (await MediaPlatform.invoke<bool>('openEmbedded', {
'url': source.url,
'title': title.title,
}) !=
true) {
throw StateError('Unavailable');
}
} else {
await _openWeb(context, source.url);
}
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not open embedded video.',
'باز کردن ویدیوی جاسازی‌شده ممکن نشد.',
),
);
}
}
return;
}
if (uri == null ||
!{'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty) {
toast(
context,
tr(
context,
'This source URL is unavailable.',
'پیوند این منبع در دسترس نیست.',
),
);
return;
}
if (external || AppScope.read(context).prefs.externalPlayer) {
try {
if (MediaPlatform.supported) {
final state = AppScope.read(context);
final previous = state.history
.where(
(entry) =>
entry.title.libraryKey == title.libraryKey &&
entry.episodeId == episode?.id,
)
.firstOrNull;
await MediaPlatform.openExternal(
source.url,
packageName: state.prefs.externalPlayerPackage,
title: title.title,
position: state.prefs.resumePlayback && previous != null
? Duration(
milliseconds: (previous.positionSeconds * 1000).round(),
)
: null,
);
} else {
await _openWeb(context, source.url);
}
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'No external player could open this source.',
'پخش‌کننده خارجی نتوانست این منبع را باز کند.',
),
);
}
}
return;
}
_push(
context,
PlayerPage(
title: title,
source: source,
episode: episode,
playlist: playlist,
alternatives: alternatives,
),
);
}
Future<void> _copy(BuildContext context, String text) async {
if (!ContentPolicy.allowsText(text)) return;
try {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
toast(context, tr(context, 'Copied to clipboard', 'در کلیپ‌بورد کپی شد'));
}
} catch (_) {
if (context.mounted) {
toast(
context,
tr(context, 'Could not copy this link', 'کپی پیوند ممکن نشد'),
);
}
}
}
Future<void> _shareTitle(BuildContext context, TitleRecord title) =>
_share(context, title.title, title.title);
Future<void> _share(BuildContext context, String text, String title) async {
if (!ContentPolicy.allowsText(text) || !ContentPolicy.allowsText(title)) {
return;
}
final box = context.findRenderObject();
final origin = box is RenderBox && box.hasSize
? box.localToGlobal(Offset.zero) & box.size
: null;
try {
await SharePlus.instance.share(
ShareParams(
text: text == title ? title : '$title\n$text',
title: title,
sharePositionOrigin: origin,
),
);
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Sharing is unavailable on this device.',
'اشتراک‌گذاری در این دستگاه در دسترس نیست.',
),
);
}
}
}
/// Optional sections load on demand, independently of the primary title request.
class _RemotePanel<T> extends StatefulWidget {
const _RemotePanel({
required this.title,
required this.load,
required this.builder,
this.initiallyExpanded = false,
});
final String title;
final Future<List<T>> Function() load;
final Widget Function(List<T>) builder;
final bool initiallyExpanded;
@override
State<_RemotePanel<T>> createState() => _RemotePanelState<T>();
}
class _RemotePanelState<T> extends State<_RemotePanel<T>> {
Future<List<T>>? _future;
@override
void initState() {
super.initState();
if (widget.initiallyExpanded) _future = widget.load();
}
@override
Widget build(BuildContext context) => ExpansionTile(
initiallyExpanded: widget.initiallyExpanded,
title: Text(
widget.title,
style: const TextStyle(fontWeight: FontWeight.w700),
),
onExpansionChanged: (expanded) {
if (expanded && _future == null) {
setState(() {
_future = widget.load();
});
}
},
children: [
if (_future != null)
AsyncResult<List<T>>(
future: _future!,
onRetry: () => setState(() {
_future = widget.load();
}),
builder: (items) => items.isEmpty
? EmptyView(
title: tr(
context,
'Nothing available here',
'موردی در دسترس نیست',
),
)
: widget.builder(items),
),
],
);
}
class _SubtitleTile extends StatelessWidget {
const _SubtitleTile({required this.item});
final Map<String, dynamic> item;
@override
Widget build(BuildContext context) {
final url = _text(item, ['url', 'link', 'file']);
return ListTile(
leading: const Icon(Icons.subtitles_outlined),
title: Text(_text(item, ['title', 'language', 'name'])),
subtitle: Text(
tr(
context,
'Open or save with your browser',
'باز کردن یا ذخیره با مرورگر',
),
),
onTap: url.isEmpty ? null : () => _openWeb(context, url),
trailing: IconButton(
onPressed: url.isEmpty ? null : () => _copy(context, url),
tooltip: tr(context, 'Copy subtitle URL', 'کپی پیوند زیرنویس'),
icon: const Icon(Icons.copy),
),
);
}
}
class _RecommendationRail extends StatefulWidget {
const _RecommendationRail({
super.key,
required this.genre,
this.excludeId = 0,
});
final int genre, excludeId;
@override
State<_RecommendationRail> createState() => _RecommendationRailState();
}
class _RecommendationRailState extends State<_RecommendationRail> {
Future<List<TitleRecord>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.read(context).api.recommended(widget.genre);
}
@override
Widget build(BuildContext context) => AsyncResult<List<TitleRecord>>(
future: _future!,
onRetry: () => setState(() {
_future = AppScope.read(context).api.recommended(widget.genre);
}),
builder: (items) => items.where((e) => e.id != widget.excludeId).isEmpty
? const SizedBox.shrink()
: PosterRail(
title: widget.excludeId > 0
? tr(context, 'More like this', 'مشابه این عنوان')
: tr(
context,
'Picked for your taste',
'پیشنهاد بر اساس سلیقه شما',
),
items: items.where((e) => e.id != widget.excludeId).toList(),
onMore: () =>
_push(context, RecommendationsPage(genre: widget.genre)),
),
);
}
class RecommendationsPage extends StatelessWidget {
const RecommendationsPage({super.key, this.genre = 0, this.byYear = false});
final int genre;
final bool byYear;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
genre > 0
? tr(context, 'Recommended', 'پیشنهادها')
: byYear
? tr(context, 'Best by year', 'بهترین‌های هر سال')
: tr(context, 'Surprise me', 'یک پیشنهاد تازه'),
),
),
body: _PagedTitles(
load: (page) => genre > 0
? AppScope.read(context).api.recommended(genre, page: page)
: AppScope.read(context).api.bestRandom(byYear: byYear, page: page),
),
);
}