diff --git a/Makefile b/Makefile index 3ee368b..9a8a863 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/lib/features/connections/service/base_connection_service.dart b/lib/features/connections/service/base_connection_service.dart index aaabcb7..9a59ad7 100644 --- a/lib/features/connections/service/base_connection_service.dart +++ b/lib/features/connections/service/base_connection_service.dart @@ -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 getItemById(LibraryItem id); - Stream> getStreams(LibraryRecord library, LibraryItem id, - {String? season, String? episode}); + Stream> getStreams( + LibraryRecord library, + LibraryItem id, { + String? season, + String? episode, + }); BaseConnectionService({ required this.connectionId, diff --git a/lib/features/connections/service/stremio_connection_service.dart b/lib/features/connections/service/stremio_connection_service.dart index 78c9e29..8325f34 100644 --- a/lib/features/connections/service/stremio_connection_service.dart +++ b/lib/features/connections/service/stremio_connection_service.dart @@ -15,6 +15,8 @@ import './base_connection_service.dart'; part 'stremio_connection_service.g.dart'; +final Map manifestCache = {}; + class StremioConnectionService extends BaseConnectionService { final StremioConfig config; @@ -155,8 +157,15 @@ class StremioConnectionService extends BaseConnectionService { } Future _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>> getFilters(LibraryRecord library) async { - return []; + final configItems = getConfig(library.config); + List> 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( + title: extraItem.name, + type: ConnectionFilterType.text, + ), + ); + } else { + filters.add( + ConnectionFilter( + title: extraItem.name, + type: ConnectionFilterType.options, + values: extraItem.options?.whereType().toList(), + ), + ); + } + } + } + } + } catch (e) {} + + return filters; } @override diff --git a/lib/features/connections/types/stremio/stremio_base.types.dart b/lib/features/connections/types/stremio/stremio_base.types.dart index d2b496e..bc0842c 100644 --- a/lib/features/connections/types/stremio/stremio_base.types.dart +++ b/lib/features/connections/types/stremio/stremio_base.types.dart @@ -105,10 +105,12 @@ class StremioManifestCatalog { String type; String id; String? name; + final List? extra; StremioManifestCatalog({ required this.id, required this.type, + this.extra, this.name, }); @@ -121,6 +123,30 @@ class StremioManifestCatalog { Map toJson() => _$StremioManifestCatalogToJson(this); } +@JsonSerializable() +class StremioManifestCatalogExtra { + final String name; + final List? options; + + StremioManifestCatalogExtra({ + required this.name, + required this.options, + }); + + factory StremioManifestCatalogExtra.fromJson(Map json) { + try { + return _$StremioManifestCatalogExtraFromJson(json); + } catch (e) { + return StremioManifestCatalogExtra( + name: "Unable to parse", + options: [], + ); + } + } + + Map toJson() => _$StremioManifestCatalogExtraToJson(this); +} + @JsonSerializable() class StremioConfig { List addons; diff --git a/lib/features/connections/widget/base/render_library_list.dart b/lib/features/connections/widget/base/render_library_list.dart index 10f5777..f7c672b 100644 --- a/lib/features/connections/widget/base/render_library_list.dart +++ b/lib/features/connections/widget/base/render_library_list.dart @@ -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 { 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 filters = []; + InfiniteQuery getQuery() { return InfiniteQuery, 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? 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, diff --git a/lib/features/connections/widget/stremio/stremio_filter.dart b/lib/features/connections/widget/stremio/stremio_filter.dart new file mode 100644 index 0000000..633b8e0 --- /dev/null +++ b/lib/features/connections/widget/stremio/stremio_filter.dart @@ -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 item); + +class InlineFilters extends StatefulWidget { + final List> filters; + final FilterCallback filterCallback; + + const InlineFilters({ + super.key, + required this.filters, + required this.filterCallback, + }); + + @override + State createState() => _InlineFiltersState(); +} + +class _InlineFiltersState extends State { + final Map _selectedValues = {}; + + List generateFilterItem() { + final List 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 filter) async { + final selectedValue = await showDialog( + 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(); + } + } +} diff --git a/lib/features/getting_started/container/create_connection.dart b/lib/features/getting_started/container/create_connection.dart index 559b10f..8a513b8 100644 --- a/lib/features/getting_started/container/create_connection.dart +++ b/lib/features/getting_started/container/create_connection.dart @@ -73,12 +73,14 @@ class _CreateConnectionStepState extends State { } } - Future _validateAddonUrl(String url) async { + Future _validateAddonUrl(String url_) async { setState(() { _isLoading = true; _errorMessage = null; }); + final url = url_.replaceFirst("stremio://", "https://"); + try { final response = await http.get( Uri.parse( diff --git a/lib/features/watch_history/service/zeee_watch_history.dart b/lib/features/watch_history/service/zeee_watch_history.dart index e40e46e..7614893 100644 --- a/lib/features/watch_history/service/zeee_watch_history.dart +++ b/lib/features/watch_history/service/zeee_watch_history.dart @@ -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(); diff --git a/lib/pages/home_tab.page.dart b/lib/pages/home_tab.page.dart index 0ca107a..31e0b06 100644 --- a/lib/pages/home_tab.page.dart +++ b/lib/pages/home_tab.page.dart @@ -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 { - 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 { 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 { ); } } + +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, + ), + ], + ); + } +} diff --git a/lib/pages/search_tab.page.dart b/lib/pages/search_tab.page.dart index 45f8c74..27b5510 100644 --- a/lib/pages/search_tab.page.dart +++ b/lib/pages/search_tab.page.dart @@ -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 { final TextEditingController _searchController = TextEditingController(); - String _selectedFilter = 'All'; Timer? _debounceTimer; bool _isSearchFocused = false; - final List _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 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 { 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 { ), ), ), - 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 { ), ); } - - 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, - ), - ], - ), - ), - ); - } }