// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/remote/catalog_api.dart'; import '../../core/data/models/playlist_detail.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/playlist_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import '../player/player_view.dart'; import 'recommend_types.dart'; import 'widgets/all_dramas_filter_grid.dart'; import 'widgets/playlist_detail_page.dart'; import 'widgets/recommend_sections_view.dart'; import 'widgets/recommend_top_nav.dart'; class RecommendPage extends StatefulWidget { const RecommendPage({ super.key, required this.feedController, this.catalogApi, required this.active, required this.onSearch, this.videoOnly = false, this.onImmersiveChanged, }); final HomeFeedController feedController; final CatalogApi? catalogApi; final bool active; final VoidCallback onSearch; /// 画中画时只留视频,不画顶栏。 final bool videoOnly; final ValueChanged? onImmersiveChanged; @override State createState() => _RecommendPageState(); } class _RecommendPageState extends State with AutomaticKeepAliveClientMixin { RecommendInnerTab _tab = RecommendInnerTab.feed; final PageController _pageController = PageController(); int _scrubCancelRequest = 0; @override bool get wantKeepAlive => true; @override void initState() { super.initState(); if (widget.active) _scheduleRecommendData(); } @override void didUpdateWidget(RecommendPage oldWidget) { super.didUpdateWidget(oldWidget); if (widget.active && !oldWidget.active) _scheduleRecommendData(); } void _scheduleRecommendData() { final feed = widget.feedController; if (!feed.loaded && !feed.loadingFirstPage) { unawaited(feed.refreshCurrent()); } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !widget.active) return; final playlists = _readPlaylists(); if (playlists != null && !playlists.loaded && !playlists.loading) { unawaited(playlists.load()); } }); } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); final categories = context.read(); return ColoredBox( key: const Key('recommend-page'), color: AppColors.appBackground, child: Stack( children: [ Positioned.fill( child: ListenableBuilder( listenable: Listenable.merge([widget.feedController, categories]), builder: (context, _) => PageView( key: const Key('recommend-tab-page-view'), controller: _pageController, scrollDirection: Axis.horizontal, onPageChanged: _onPageChanged, children: [ _KeepAliveTab( key: const PageStorageKey('recommend-tab-feed'), child: _recommendFeed(context), ), _KeepAliveTab( key: const PageStorageKey('recommend-tab-playlists'), child: _recommendPlaylists(categories), ), _KeepAliveTab( key: const PageStorageKey('recommend-tab-all'), child: _withTopNavSpace( AllDramasFilterGrid( feedController: widget.feedController, catalogApi: widget.catalogApi, onOpenDrama: _openDrama, ), ), ), ], ), ), ), if (!widget.videoOnly) Positioned( top: 0, left: 0, right: 0, child: IgnorePointer( child: SizedBox( height: 72, child: DecoratedBox( key: const Key('recommend-top-gradient'), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [AppColors.mediaBlack, AppColors.transparent], ), ), ), ), ), ), if (!widget.videoOnly) Positioned( top: 0, left: 0, right: 0, child: SafeArea( bottom: false, child: RecommendTopNav( selected: _tab, onVideo: _tab == RecommendInnerTab.feed, onSelected: _selectTab, onSearch: _handleSearch, ), ), ), ], ), ); } Widget _recommendFeed(BuildContext context) { final dramas = widget.feedController.dramas; if (dramas.isEmpty) { final l10n = AppLocalizations.of(context)!; if (widget.feedController.loadingFirstPage) { return const _RecommendFeedSkeleton(); } return ColoredBox( color: AppColors.mediaBlack, child: EmptyState( key: const Key('recommend-feed-empty'), icon: Icons.movie_outlined, title: l10n.noStories, actionLabel: l10n.retry, onAction: () => unawaited(widget.feedController.refreshCurrent()), ), ); } return PlayerPage( key: const Key('recommend-feed-player'), homeMode: true, embedded: true, active: widget.active && _tab == RecommendInnerTab.feed, showHomeFeedHeader: false, drama: dramas.first, initialIndex: 0, feedDramas: dramas, feedChannel: widget.feedController.channel, onOpenFullPlayer: (drama, episode) => _openDrama(drama, episodeId: episode.id), onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, onImmersiveChanged: widget.onImmersiveChanged, scrubCancelRequest: _scrubCancelRequest, ); } PlaylistController? _watchPlaylists(BuildContext context) { try { return context.watch(); } on ProviderNotFoundException { return null; } } PlaylistController? _readPlaylists() { try { return context.read(); } on ProviderNotFoundException { return null; } } Widget _recommendPlaylists(CategoryController categories) { return DecoratedBox( key: const Key('recommend-playlist-surface'), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppColors.mineGradientTop, AppColors.mineGradientMiddle, AppColors.mineGradientBottom, ], stops: [0, 0.4, 0.75], ), ), child: Stack( fit: StackFit.expand, children: [ const DecoratedBox( key: Key('recommend-playlist-glow'), decoration: BoxDecoration( gradient: RadialGradient( center: Alignment(0, -0.95), radius: 0.9, colors: [ AppColors.recommendPlaylistGlow, AppColors.transparent, ], ), ), ), _withTopNavSpace( RecommendSectionsView( feedController: widget.feedController, categories: categories, onOpenDrama: _openDrama, onMore: _handleMore, playlists: _watchPlaylists(context), onOpenPlaylist: _openPlaylist, ), ), ], ), ); } void _selectTab(RecommendInnerTab tab) { setState(() { _tab = tab; _scrubCancelRequest++; }); if (_pageController.hasClients) { unawaited( _pageController.animateToPage( tab.index, duration: const Duration(milliseconds: 220), curve: Curves.easeOut, ), ); } } void _onPageChanged(int index) { final next = RecommendInnerTab.values[index]; if (next == _tab) return; setState(() { _tab = next; _scrubCancelRequest++; }); } void _handleSearch() { setState(() => _scrubCancelRequest++); widget.onSearch(); } Widget _withTopNavSpace(Widget child) { return SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.only(top: RecommendTopNav.height), child: child, ), ); } Future _openPlaylist(PlaylistDetail playlist) async { final controller = _readPlaylists(); const pageSize = CatalogApi.playlistDramaPageSize; final fresh = controller == null ? playlist : await controller.detail(playlist.id, pageSize: pageSize); if (!mounted) return; final shown = fresh.dramas.isEmpty && playlist.dramas.isNotEmpty ? playlist : fresh; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlaylistDetailPage( title: shown.name, subtitle: shown.description, source: shown, dramas: shown.dramas, dramaCount: shown.dramaCount, loadPage: controller == null ? null : (pageIndex) => controller.detail( playlist.id, pageIndex: pageIndex, pageSize: pageSize, ), ), ), ); } void _handleMore(RecommendSectionKind section) { final l10n = AppLocalizations.of(context)!; final copy = _sectionCopy(l10n, section); Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlaylistDetailPage( title: copy.$1, subtitle: copy.$2, dramas: recommendSectionDramas( kind: section, source: widget.feedController.dramas, categories: context.read(), ), ), ), ); } (String, String) _sectionCopy( AppLocalizations l10n, RecommendSectionKind section, ) { return switch (section) { RecommendSectionKind.hits => ( l10n.weeklyHitsTitle, l10n.weeklyHitsSubtitle, ), RecommendSectionKind.newReleases => ( l10n.weeklyNewTitle, l10n.weeklyNewSubtitle, ), RecommendSectionKind.costume => ( l10n.costumeAwardsTitle, l10n.costumeAwardsSubtitle, ), }; } Future _openDrama(Drama drama, {String? episodeId}) async { await playDramaFromCatalog(context, drama, episodeId: episodeId); } } class _RecommendFeedSkeleton extends StatelessWidget { const _RecommendFeedSkeleton(); @override Widget build(BuildContext context) { return const ColoredBox( key: Key('recommend-feed-skeleton'), color: AppColors.mediaBlack, child: Stack( children: [ Center( child: SizedBox( width: 132, height: 176, child: DecoratedBox( decoration: BoxDecoration( color: AppColors.skeletonSurface, borderRadius: BorderRadius.all(Radius.circular(12)), ), ), ), ), Positioned( left: 14, right: 72, bottom: 42, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SkeletonLine(width: 190, height: 18), SizedBox(height: 10), _SkeletonLine(width: 140, height: 12), SizedBox(height: 8), _SkeletonLine(width: 220, height: 10), ], ), ), ], ), ); } } class _SkeletonLine extends StatelessWidget { const _SkeletonLine({required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( width: width, height: height, decoration: BoxDecoration( color: AppColors.skeletonSurface, borderRadius: BorderRadius.circular(6), ), ); } } class _KeepAliveTab extends StatefulWidget { const _KeepAliveTab({super.key, required this.child}); final Widget child; @override State<_KeepAliveTab> createState() => _KeepAliveTabState(); } class _KeepAliveTabState extends State<_KeepAliveTab> with AutomaticKeepAliveClientMixin<_KeepAliveTab> { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return widget.child; } }