Project import generated by Copybara.

GitOrigin-RevId: 7a1d4421cba8507811cf2b329e63a7065546b86f
This commit is contained in:
Madari Developers 2025-01-05 09:13:37 +00:00
parent 6e360022ca
commit 2a0e255f62
10 changed files with 530 additions and 245 deletions

View file

@ -10,10 +10,10 @@ build_web:
flutter build web --target lib/main_web.dart --release --pwa-strategy none --wasm
build_mac:
flutter build macos --target lib/main.dart --release --no-tree-shake-icons
flutter build macos --target lib/main.dart --release
build_android:
flutter build apk --release --no-tree-shake-icons
flutter build apk --release
build_windows:
flutter build windows --release --no-tree-shake-icons
flutter build windows --release

View file

@ -32,7 +32,6 @@ abstract class BaseConnectionService {
Connection item,
ConnectionTypeRecord type,
) {
print(type);
switch (type.type) {
case "stremio_addons":
return StremioConnectionService(
@ -91,8 +90,12 @@ abstract class BaseConnectionService {
Future<LibraryItem?> getItemById(LibraryItem id);
Stream<List<StreamList>> getStreams(LibraryRecord library, LibraryItem id,
{String? season, String? episode});
Stream<List<StreamList>> getStreams(
LibraryRecord library,
LibraryItem id, {
String? season,
String? episode,
});
BaseConnectionService({
required this.connectionId,

View file

@ -15,6 +15,8 @@ import './base_connection_service.dart';
part 'stremio_connection_service.g.dart';
final Map<String, String> manifestCache = {};
class StremioConnectionService extends BaseConnectionService {
final StremioConfig config;
@ -155,8 +157,15 @@ class StremioConnectionService extends BaseConnectionService {
}
Future<StremioManifest> _getManifest(String url) async {
final result = await http.get(Uri.parse(url));
final body = jsonDecode(result.body);
final String result;
if (manifestCache.containsKey(url)) {
result = manifestCache[url]!;
} else {
result = (await http.get(Uri.parse(url))).body;
manifestCache[url] = result;
}
final body = jsonDecode(result);
final resultFinal = StremioManifest.fromJson(body);
return resultFinal;
}
@ -169,7 +178,49 @@ class StremioConnectionService extends BaseConnectionService {
@override
Future<List<ConnectionFilter<T>>> getFilters<T>(LibraryRecord library) async {
return [];
final configItems = getConfig(library.config);
List<ConnectionFilter<T>> filters = [];
try {
for (final addon in configItems) {
final addonManifest = await _getManifest(addon.addon);
if ((addonManifest.catalogs?.isEmpty ?? true) == true) {
continue;
}
final catalogs = addonManifest.catalogs!.where((item) {
return item.id == addon.item.id && item.type == addon.item.type;
}).toList();
for (final catalog in catalogs) {
if (catalog.extra == null) {
continue;
}
for (final extraItem in catalog.extra!) {
if (extraItem.options == null ||
extraItem.options?.isEmpty == true) {
filters.add(
ConnectionFilter<T>(
title: extraItem.name,
type: ConnectionFilterType.text,
),
);
} else {
filters.add(
ConnectionFilter<T>(
title: extraItem.name,
type: ConnectionFilterType.options,
values: extraItem.options?.whereType<T>().toList(),
),
);
}
}
}
}
} catch (e) {}
return filters;
}
@override

View file

@ -105,10 +105,12 @@ class StremioManifestCatalog {
String type;
String id;
String? name;
final List<StremioManifestCatalogExtra>? extra;
StremioManifestCatalog({
required this.id,
required this.type,
this.extra,
this.name,
});
@ -121,6 +123,30 @@ class StremioManifestCatalog {
Map<String, dynamic> toJson() => _$StremioManifestCatalogToJson(this);
}
@JsonSerializable()
class StremioManifestCatalogExtra {
final String name;
final List<dynamic>? options;
StremioManifestCatalogExtra({
required this.name,
required this.options,
});
factory StremioManifestCatalogExtra.fromJson(Map<String, dynamic> json) {
try {
return _$StremioManifestCatalogExtraFromJson(json);
} catch (e) {
return StremioManifestCatalogExtra(
name: "Unable to parse",
options: [],
);
}
}
Map<String, dynamic> toJson() => _$StremioManifestCatalogExtraToJson(this);
}
@JsonSerializable()
class StremioConfig {
List<String> addons;

View file

@ -8,6 +8,7 @@ import 'package:pocketbase/pocketbase.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../utils/grid.dart';
import '../stremio/stremio_filter.dart';
final pb = AppEngine.engine.pb;
@ -41,7 +42,9 @@ class _RenderLibraryListState extends State<RenderLibraryList> {
query: query,
builder: (ctx, state) {
if (state.status == QueryStatus.loading) {
return const SpinnerCards();
return const Center(
child: SpinnerCards(),
);
}
if (state.status == QueryStatus.error) {
@ -140,6 +143,8 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
loadFilters();
}
@override
@ -150,22 +155,22 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
super.dispose();
}
List<ConnectionFilterItem> filters = [];
InfiniteQuery getQuery() {
return InfiniteQuery<List<LibraryItem>, int>(
key:
"loadLibrary${widget.item.id}${widget.filters.map((res) => "${res.title}=${res.value}").join("&")}",
"loadLibrary${widget.item.id}${(widget.filters + filters).map((res) => "${res.title}=${res.value}").join("&")}",
queryFn: (page) {
return service
.getItems(
widget.item,
items: widget.filters,
items: widget.filters + filters,
page: page,
)
.then((docs) {
return docs.items.toList();
}).catchError((e, stack) {
print(e);
print(stack);
throw e;
});
},
@ -180,8 +185,94 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
bool isUnsupported = false;
loadFilters() async {
final filters = await service.getFilters(widget.item);
if (mounted) {
setState(() {
filterList = filters;
});
}
}
List<ConnectionFilter>? filterList;
@override
Widget build(BuildContext context) {
if (widget.isGrid) {
return Scaffold(
appBar: AppBar(
title: Text(widget.item.title),
),
body: SizedBox(
height: MediaQuery.of(context).size.height - 96,
child: Flex(
direction: Axis.vertical,
children: [
const SizedBox(
height: 10,
),
if (filterList == null)
Row(
children: [
SizedBox(
height: 36,
width: 120,
child: Padding(
padding: const EdgeInsets.only(
left: 10.0,
right: 10.0,
),
child: Container(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(20),
),
child: const SizedBox(
height: 36,
width: 120,
),
),
),
),
],
),
if (filterList != null)
InlineFilters(
filters: filterList ?? [],
filterCallback: (item) {
filters = item;
setState(() {
query = getQuery();
});
},
),
const SizedBox(
height: 10,
),
Expanded(
child: SizedBox(
height: MediaQuery.of(context).size.height - 96,
child: Padding(
padding: const EdgeInsets.only(
left: 10.0,
right: 10.0,
),
child: _buildBody(),
),
),
),
],
),
),
);
}
return _buildBody();
}
_buildBody() {
final itemWidth = _getItemWidth(context);
final listHeight = _getListHeight(context);
@ -320,6 +411,7 @@ class SpinnerCards extends StatelessWidget {
height: itemHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, _) {
return SizedBox(
width: itemWidth,

View file

@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:madari_client/features/connection/services/stremio_service.dart';
import '../../service/base_connection_service.dart';
typedef FilterCallback = void Function(List<ConnectionFilterItem> item);
class InlineFilters extends StatefulWidget {
final List<ConnectionFilter<dynamic>> filters;
final FilterCallback filterCallback;
const InlineFilters({
super.key,
required this.filters,
required this.filterCallback,
});
@override
State<InlineFilters> createState() => _InlineFiltersState();
}
class _InlineFiltersState extends State<InlineFilters> {
final Map<String, dynamic> _selectedValues = {};
List<ConnectionFilterItem> generateFilterItem() {
final List<ConnectionFilterItem> items = [];
for (final item in _selectedValues.keys) {
items.add(
ConnectionFilterItem(title: item, value: _selectedValues[item]!),
);
}
return items;
}
onChange() {
widget.filterCallback(generateFilterItem());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: widget.filters
.where((filter) => filter.type == ConnectionFilterType.options)
.map((filter) {
final isSelected = _selectedValues.containsKey(filter.title);
return Center(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: InputChip(
label: Text(
(isSelected ? _selectedValues[filter.title] : filter.title)
.toString()
.capitalize(),
style: TextStyle(
fontSize: 14,
color: theme.textTheme.bodyMedium?.color,
),
),
selected: isSelected,
onPressed: () {
if (isSelected) {
setState(() {
_selectedValues.remove(filter.title);
});
onChange();
} else {
_showOptionsDialog(filter);
}
},
deleteIcon: isSelected
? const Icon(
Icons.close,
)
: null,
onDeleted: isSelected
? () {
setState(() {
_selectedValues.remove(filter.title);
onChange();
});
}
: null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
);
}).toList(),
),
);
}
void _showOptionsDialog(ConnectionFilter<dynamic> filter) async {
final selectedValue = await showDialog<dynamic>(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
title: Text(filter.title),
children: (filter.values ?? []).map((value) {
return SimpleDialogOption(
onPressed: () {
Navigator.pop(context, value);
},
child: Text(value.toString()),
);
}).toList(),
);
},
);
if (selectedValue != null) {
setState(() {
_selectedValues[filter.title] = selectedValue;
});
onChange();
}
}
}

View file

@ -73,12 +73,14 @@ class _CreateConnectionStepState extends State<CreateConnectionStep> {
}
}
Future<void> _validateAddonUrl(String url) async {
Future<void> _validateAddonUrl(String url_) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final url = url_.replaceFirst("stremio://", "https://");
try {
final response = await http.get(
Uri.parse(

View file

@ -39,6 +39,10 @@ class ZeeeWatchHistory extends BaseWatchHistory {
ZeeeWatchHistory() {
_listener = AppEngine.engine.pb.authStore.onChange.listen((auth) {
if (!AppEngine.engine.pb.authStore.isValid) {
return;
}
_initializeFromServer().then((docs) {
if (_syncTimer != null) {
_syncTimer!.cancel();

View file

@ -2,6 +2,7 @@ import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:madari_client/engine/library.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import '../features/connections/widget/base/render_library_list.dart';
@ -10,6 +11,7 @@ import '../features/getting_started/container/getting_started.dart';
class HomeTabPage extends StatefulWidget {
final String? search;
final bool hideAppBar;
final LibraryRecordResponse? defaultLibraries;
static String get routeName => "/";
@ -17,6 +19,7 @@ class HomeTabPage extends StatefulWidget {
super.key,
this.search,
this.hideAppBar = kIsWeb,
this.defaultLibraries,
});
@override
@ -24,10 +27,18 @@ class HomeTabPage extends StatefulWidget {
}
class _HomeTabPageState extends State<HomeTabPage> {
final query = Query(
queryFn: () => BaseConnectionService.getLibraries(),
late final query = Query(
queryFn: () {
if (widget.defaultLibraries != null) {
return Future.value(
widget.defaultLibraries,
);
}
return BaseConnectionService.getLibraries();
},
key: [
"home",
"home${widget.defaultLibraries?.data.length ?? 0}${widget.search ?? ""}",
],
);
@ -54,128 +65,111 @@ class _HomeTabPageState extends State<HomeTabPage> {
style: GoogleFonts.montserrat(),
),
),
body: QueryBuilder(
query: query,
builder: (context, state) {
if (QueryStatus.error == state.status) {
return _buildError(state.error);
}
body: RefreshIndicator(
onRefresh: () async {
await query.refetch();
return;
},
child: QueryBuilder(
query: query,
builder: (context, state) {
if (QueryStatus.error == state.status) {
return _buildError(state.error);
}
final data = state.data;
final data = state.data;
if (data == null) {
return const Text("Loading");
}
if (data == null) {
return const Text("Loading");
}
if (data.data.isEmpty) {
return Padding(
padding: const EdgeInsets.only(
bottom: 24,
left: 12,
right: 12,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: GettingStartedScreen(
onCallback: () {
query.refetch();
},
if (data.data.isEmpty) {
return Padding(
padding: const EdgeInsets.only(
bottom: 24,
left: 12,
right: 12,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: GettingStartedScreen(
onCallback: () {
query.refetch();
},
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
);
}
child: ListView.builder(
itemBuilder: (item, index) {
final item = data.data[index];
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
child: ListView.builder(
itemBuilder: (item, index) {
final item = data.data[index];
return Container(
margin: const EdgeInsets.only(bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
item.title,
style: theme.textTheme.bodyLarge,
),
const Spacer(),
SizedBox(
height: 30,
child: TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: Text(item.title),
),
body: SizedBox(
height: MediaQuery.of(context)
.size
.height -
96,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: RenderLibraryList(
item: item,
isGrid: true,
filters: [
if ((widget.search ?? "")
.trim() !=
"")
ConnectionFilterItem(
title: "search",
value: widget.search,
),
],
),
),
),
);
},
return Container(
margin: const EdgeInsets.only(bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
item.title,
style: theme.textTheme.bodyLarge,
),
const Spacer(),
SizedBox(
height: 30,
child: TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return ShowMorePage(
item: item,
search: widget.search,
);
},
),
);
},
child: Text(
"Show more",
style: theme.textTheme.labelMedium?.copyWith(
color: Colors.white70,
),
);
},
child: Text(
"Show more",
style: theme.textTheme.labelMedium?.copyWith(
color: Colors.white70,
),
),
),
),
],
),
const SizedBox(
height: 8,
),
RenderLibraryList(
item: item,
filters: [
if ((widget.search ?? "").trim() != "")
ConnectionFilterItem(
title: "search",
value: widget.search,
),
],
),
],
),
);
},
itemCount: data.data.length,
),
);
},
],
),
const SizedBox(
height: 8,
),
RenderLibraryList(
item: item,
filters: [
if ((widget.search ?? "").trim() != "")
ConnectionFilterItem(
title: "search",
value: widget.search,
),
],
),
],
),
);
},
itemCount: data.data.length,
),
);
},
),
),
);
}
@ -220,3 +214,29 @@ class _HomeTabPageState extends State<HomeTabPage> {
);
}
}
class ShowMorePage extends StatelessWidget {
final LibraryRecord item;
final String? search;
const ShowMorePage({
super.key,
required this.item,
required this.search,
});
@override
Widget build(BuildContext context) {
return RenderLibraryList(
item: item,
isGrid: true,
filters: [
if ((search ?? "").trim() != "")
ConnectionFilterItem(
title: "search",
value: search,
),
],
);
}
}

View file

@ -2,6 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../engine/engine.dart';
import '../features/connections/service/base_connection_service.dart';
import '../features/connections/types/base/base.dart';
import 'home_tab.page.dart';
class SearchPage extends StatefulWidget {
@ -15,15 +18,63 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> {
final TextEditingController _searchController = TextEditingController();
String _selectedFilter = 'All';
Timer? _debounceTimer;
bool _isSearchFocused = false;
final List<String> _filterOptions = ['All', 'Videos', 'PDFs', 'Images'];
String _debouncedSearchTerm = '';
LibraryRecordResponse? searchLibrariesList;
@override
void initState() {
super.initState();
loadLibrariesWhichSupportSearch();
}
loadLibrariesWhichSupportSearch() async {
final library =
await AppEngine.engine.pb.collection("library").getFullList();
final record = library
.map(
(item) => LibraryRecord.fromRecord(item),
)
.where((item) {
return item.connectionType == "stremio_addons";
}).toList();
final List<LibraryRecord> records = [];
for (final item in record) {
final result =
await BaseConnectionService.connectionByIdRaw(item.connection);
final service = BaseConnectionService.connectionById(result);
final filters = await service.getFilters(item);
final hasFilter = filters.where((item) {
return item.title == "search";
}).isNotEmpty;
if (hasFilter) {
records.add(item);
if (mounted) {
searchLibrariesList = LibraryRecordResponse(
data: records,
);
setState(() {});
}
}
}
searchLibrariesList = LibraryRecordResponse(
data: records,
);
if (mounted) {
setState(() {});
}
}
@override
@ -46,7 +97,7 @@ class _SearchPageState extends State<SearchPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size(double.infinity, 114),
preferredSize: const Size(double.infinity, 76),
child: Container(
color: Colors.grey[900],
padding: const EdgeInsets.symmetric(
@ -96,47 +147,32 @@ class _SearchPageState extends State<SearchPage> {
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filterOptions.length,
separatorBuilder: (context, index) =>
const SizedBox(width: 8),
itemBuilder: (context, index) {
final filter = _filterOptions[index];
final isSelected = _selectedFilter == filter;
return FilterChip(
label: Text(
filter,
),
visualDensity: VisualDensity.compact,
selected: isSelected,
showCheckmark: false,
onSelected: (bool selected) {
setState(() => _selectedFilter = filter);
},
);
},
),
),
],
),
),
),
),
body: _debouncedSearchTerm.isEmpty
? Center(
child: _buildEmptyState(),
)
: HomeTabPage(
hideAppBar: true,
search: _debouncedSearchTerm,
),
body: RefreshIndicator(
child: _buildBody(),
onRefresh: () {
return loadLibrariesWhichSupportSearch();
},
),
);
}
Widget _buildBody() {
return _debouncedSearchTerm.isEmpty
? Center(
child: _buildEmptyState(),
)
: HomeTabPage(
hideAppBar: true,
search: _debouncedSearchTerm,
defaultLibraries: searchLibrariesList,
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
@ -160,83 +196,4 @@ class _SearchPageState extends State<SearchPage> {
),
);
}
Widget _buildSearchResults() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).size.width > 1200
? 5
: MediaQuery.of(context).size.width > 800
? 4
: MediaQuery.of(context).size.width > 600
? 3
: 2,
childAspectRatio: 16 / 9,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: 200,
),
itemBuilder: (context, index) => _buildResultCard(index),
itemCount: 20,
),
);
}
Widget _buildResultCard(int index) {
return InkWell(
onTap: () {},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(2),
),
child: Center(
child: Icon(
_selectedFilter == 'PDFs'
? Icons.picture_as_pdf
: _selectedFilter == 'Videos'
? Icons.play_circle_filled
: Icons.image,
size: 40,
color: Colors.grey[400],
),
),
),
),
const SizedBox(height: 8),
Text(
'Title ${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'2024 • Category',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
}