[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,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.',
|
||||
'بهروزرسانی منبع پخش ممکن نشد. جزئیات عنوان را باز کنید و دوباره تلاش کنید.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user