diff --git a/lib/models/recommended_model.dart b/lib/models/recommended_model.dart index 733a245..2ed5b31 100644 --- a/lib/models/recommended_model.dart +++ b/lib/models/recommended_model.dart @@ -12,6 +12,13 @@ sealed class NameSwitch { String label(BuildContext context); } +class Resume extends NameSwitch { + const Resume(); + + @override + String label(BuildContext context) => context.localized.dashboardContinue; +} + class NextUp extends NameSwitch { const NextUp(); diff --git a/lib/providers/library_screen_provider.dart b/lib/providers/library_screen_provider.dart index e43285b..a4aaffb 100644 --- a/lib/providers/library_screen_provider.dart +++ b/lib/providers/library_screen_provider.dart @@ -90,23 +90,32 @@ class LibraryScreen extends _$LibraryScreen { return null; } + Future loadResume(ViewModel viewModel) async {} + Future loadRecommendations(ViewModel viewModel) async { List newRecommendations = []; - final latest = await api.usersUserIdItemsLatestGet( + + final resume = await api.usersUserIdItemsResumeGet( parentId: viewModel.id, limit: 14, - isPlayed: false, - imageTypeLimit: 1, - includeItemTypes: viewModel.collectionType.itemKinds.map((e) => e.dtoKind).toList(), + enableUserData: true, + enableImageTypes: [ + ImageType.primary, + ImageType.banner, + ImageType.screenshot, + ], + mediaTypes: [MediaType.video], + enableTotalRecordCount: false, ); newRecommendations = [ ...newRecommendations, RecommendedModel( - name: const Latest(), - posters: latest.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [], + name: const Resume(), + posters: resume.body?.items?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [], type: null, ), ]; + if (viewModel.collectionType == CollectionType.movies) { final response = await api.moviesRecommendationsGet( parentId: viewModel.id, @@ -146,6 +155,22 @@ class LibraryScreen extends _$LibraryScreen { ]; } + final latest = await api.usersUserIdItemsLatestGet( + parentId: viewModel.id, + limit: 14, + isPlayed: false, + imageTypeLimit: 1, + includeItemTypes: viewModel.collectionType.itemKinds.map((e) => e.dtoKind).toList(), + ); + newRecommendations = [ + ...newRecommendations, + RecommendedModel( + name: const Latest(), + posters: latest.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList() ?? [], + type: null, + ), + ]; + state = state.copyWith( recommendations: newRecommendations, ); diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart index d6baa02..c9ad1e7 100644 --- a/lib/screens/library/library_screen.dart +++ b/lib/screens/library/library_screen.dart @@ -40,6 +40,9 @@ class LibraryScreen extends ConsumerStatefulWidget { class _LibraryScreenState extends ConsumerState with SingleTickerProviderStateMixin { final GlobalKey? refreshKey = GlobalKey(); + + bool refreshing = false; + @override Widget build(BuildContext context) { final libraryScreenState = ref.watch(libraryScreenProvider); @@ -60,152 +63,170 @@ class _LibraryScreenState extends ConsumerState with SingleTicker body: PullToRefresh( refreshOnStart: true, refreshKey: refreshKey, - onRefresh: () => ref.read(libraryScreenProvider.notifier).fetchAllLibraries(), - child: SizedBox.expand( - child: CustomScrollView( - controller: AdaptiveLayout.scrollOf(context, HomeTabs.library), - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - const DefaultSliverTopBadding(), - if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) - NestedSliverAppBar( - route: LibrarySearchRoute(), - parent: context, - ), - if (views.isNotEmpty) - SliverToBoxAdapter( - child: LibraryRow( - padding: padding, - views: views, - selectedView: libraryScreenState.selectedViewModel, - onSelected: (view) { - ref.read(libraryScreenProvider.notifier).selectLibrary(view); - refreshKey?.currentState?.show(); - }, + onRefresh: () async { + if (refreshing) return; + setState(() => refreshing = true); + try { + await ref.read(libraryScreenProvider.notifier).fetchAllLibraries(); + } finally { + if (mounted) { + setState(() => refreshing = false); + } + } + }, + child: AnimatedOpacity( + opacity: refreshing ? 0.75 : 1.0, + duration: const Duration(milliseconds: 175), + child: SizedBox.expand( + child: CustomScrollView( + controller: AdaptiveLayout.scrollOf(context, HomeTabs.library), + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + const DefaultSliverTopBadding(), + if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) + NestedSliverAppBar( + route: LibrarySearchRoute(), + parent: context, ), - ), - if (selectedView != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 24, bottom: 16), - child: SizedBox( - height: 40, - child: ListView( - padding: padding, - shrinkWrap: true, - scrollDirection: Axis.horizontal, - children: [ - FilledButton.tonalIcon( - onPressed: () => context.pushRoute(LibrarySearchRoute(viewModelId: selectedView.id)), - label: Text("${context.localized.search} ${selectedView.name}..."), - icon: const Icon(IconsaxPlusLinear.search_normal), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 4.0), - child: VerticalDivider(), - ), - ExpressiveButtonGroup( - multiSelection: true, - options: LibraryViewType.values - .map((element) => ButtonGroupOption( - value: element, - icon: Icon(element.icon), - selected: Icon(element.iconSelected), - child: Text( - element.label(context), - ))) - .toList(), - selectedValues: viewTypes, - onSelected: (value) { - ref.read(libraryScreenProvider.notifier).setViewType(value); - }, - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 4.0), - child: VerticalDivider(), - ), - ElevatedButton.icon( - onPressed: () => showRefreshPopup(context, selectedView.id, selectedView.name), - label: Text(context.localized.scanLibrary), - icon: const Icon(IconsaxPlusLinear.refresh), - ), - ], - ), + if (views.isNotEmpty) + SliverToBoxAdapter( + child: LibraryRow( + padding: padding, + views: views, + selectedView: libraryScreenState.selectedViewModel, + onSelected: (view) { + if (refreshing) return; + ref.read(libraryScreenProvider.notifier).selectLibrary(view); + refreshKey?.currentState?.show(); + }, ), ), - ), - if (viewTypes.isEmpty) - SliverFillRemaining( - child: Center(child: Text(context.localized.noResults)), - ), - if (viewTypes.contains(LibraryViewType.recommended)) ...[ - if (recommendations.isNotEmpty) - ...recommendations.where((element) => element.posters.isNotEmpty).map( - (element) => SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: PosterRow( - contentPadding: padding, - posters: element.posters, - label: element.type != null - ? "${element.type?.label(context)} - ${element.name.label(context)}" - : element.name.label(context), - ), - ), - ), - ), - ], - if (viewTypes.contains(LibraryViewType.favourites)) - if (favourites.isNotEmpty) + if (selectedView != null) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: PosterRow( - contentPadding: padding, - onLabelClick: () => context.pushRoute( - LibrarySearchRoute( - viewModelId: libraryScreenState.selectedViewModel?.id ?? "", - ).withFilter( - const LibraryFilterModel( - favourites: true, - recursive: true, + padding: const EdgeInsets.only(top: 24, bottom: 16), + child: SizedBox( + height: 40, + child: ListView( + padding: padding, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: [ + FilledButton.tonalIcon( + onPressed: () => context.pushRoute(LibrarySearchRoute(viewModelId: selectedView.id)), + label: Text("${context.localized.search} ${selectedView.name}..."), + icon: const Icon(IconsaxPlusLinear.search_normal), ), - ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4.0), + child: VerticalDivider(), + ), + ExpressiveButtonGroup( + multiSelection: true, + options: LibraryViewType.values + .map((element) => ButtonGroupOption( + value: element, + icon: Icon(element.icon), + selected: Icon(element.iconSelected), + child: Text( + element.label(context), + ))) + .toList(), + selectedValues: viewTypes, + onSelected: (value) { + ref.read(libraryScreenProvider.notifier).setViewType(value); + }, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4.0), + child: VerticalDivider(), + ), + ElevatedButton.icon( + onPressed: () => showRefreshPopup(context, selectedView.id, selectedView.name), + label: Text(context.localized.scanLibrary), + icon: const Icon(IconsaxPlusLinear.refresh), + ), + ], ), - posters: favourites, - label: context.localized.favorites, ), ), ), - if (viewTypes.contains(LibraryViewType.genres)) ...[ - if (genres.isNotEmpty) - ...genres.where((element) => element.posters.isNotEmpty).map( - (element) => SliverToBoxAdapter( + if (viewTypes.isEmpty) + SliverFillRemaining( + child: Center(child: Text(context.localized.noResults)), + ), + if (viewTypes.contains(LibraryViewType.recommended)) ...[ + if (recommendations.isNotEmpty) + ...recommendations.where((element) => element.posters.isNotEmpty).map( + (element) { + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: PosterRow( contentPadding: padding, posters: element.posters, - onLabelClick: () => context.pushRoute( - LibrarySearchRoute( - viewModelId: libraryScreenState.selectedViewModel?.id ?? "", - ).withFilter( - LibraryFilterModel( - recursive: true, - genres: {(element.name as Other).customLabel: true}, - ), - ), - ), + primaryPosters: element.name is Resume, label: element.type != null ? "${element.type?.label(context)} - ${element.name.label(context)}" : element.name.label(context), ), ), + ); + }, + ), + ], + if (viewTypes.contains(LibraryViewType.favourites)) + if (favourites.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: PosterRow( + contentPadding: padding, + onLabelClick: () => context.pushRoute( + LibrarySearchRoute( + viewModelId: libraryScreenState.selectedViewModel?.id ?? "", + ).withFilter( + const LibraryFilterModel( + favourites: true, + recursive: true, + ), + ), + ), + posters: favourites, + label: context.localized.favorites, ), - ) + ), + ), + if (viewTypes.contains(LibraryViewType.genres)) ...[ + if (genres.isNotEmpty) + ...genres.where((element) => element.posters.isNotEmpty).map( + (element) => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: PosterRow( + contentPadding: padding, + posters: element.posters, + onLabelClick: () => context.pushRoute( + LibrarySearchRoute( + viewModelId: libraryScreenState.selectedViewModel?.id ?? "", + ).withFilter( + LibraryFilterModel( + recursive: true, + genres: {(element.name as Other).customLabel: true}, + ), + ), + ), + label: element.type != null + ? "${element.type?.label(context)} - ${element.name.label(context)}" + : element.name.label(context), + ), + ), + ), + ) + ], + const DefautlSliverBottomPadding(), ], - const DefautlSliverBottomPadding(), - ], + ), ), ), ), @@ -297,12 +318,27 @@ class LibraryRow extends ConsumerWidget { ), ), ), - Text( - view.name, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.start, + Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isSelected) + Container( + height: 12, + width: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primary, + ), + ), + Text( + view.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ) + ], ) ], );