// 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/download_controller.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/playlist_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; import '../player/open_player.dart'; import '../player/player_view.dart'; import 'home_tab_motion_controller.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'; part 'recommend_page_parts.dart'; class RecommendPage extends StatefulWidget { const RecommendPage({ super.key, required this.feedController, this.catalogApi, required this.active, required this.onSearch, this.onOpenWallet, this.videoOnly = false, this.onImmersiveChanged, }); final HomeFeedController feedController; final CatalogApi? catalogApi; final bool active; final VoidCallback onSearch; /// 顶部金色礼物盒(钱包入口)。 final VoidCallback? onOpenWallet; /// 画中画时只留视频,不画顶栏。 final bool videoOnly; final ValueChanged? onImmersiveChanged; @override State createState() => _RecommendPageState(); } class _RecommendPageState extends State with AutomaticKeepAliveClientMixin { static const Duration _recommendRetryDelay = Duration(milliseconds: 1500); static const int _maxRecommendRetries = 3; RecommendInnerTab _tab = RecommendInnerTab.feed; late final _pageController = HomeTabMotionController( onSettled: _onPageChanged, ); int _scrubCancelRequest = 0; bool _recommendDataRefreshScheduled = false; bool _recommendRefreshInFlight = false; int _recommendRetryAttempts = 0; Timer? _recommendRetryTimer; HomeFeedChannel? _observedFeedChannel; @override bool get wantKeepAlive => true; @override void initState() { super.initState(); _observedFeedChannel = widget.feedController.channel; widget.feedController.addListener(_handleFeedControllerChanged); if (widget.active) _scheduleRecommendData(); } @override void didUpdateWidget(RecommendPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.feedController != widget.feedController) { oldWidget.feedController.removeListener(_handleFeedControllerChanged); _cancelRecommendRetries(); _observedFeedChannel = widget.feedController.channel; widget.feedController.addListener(_handleFeedControllerChanged); } if (oldWidget.active && !widget.active) { _cancelRecommendRetries(); } if (_requiresRecommendDataRefresh(oldWidget)) { _scheduleRecommendData(); } } bool _requiresRecommendDataRefresh(RecommendPage oldWidget) { if (!widget.active) return false; return !oldWidget.active || oldWidget.feedController != widget.feedController || oldWidget.catalogApi != widget.catalogApi; } void _handleFeedControllerChanged() { final channel = widget.feedController.channel; if (channel != _observedFeedChannel) { _observedFeedChannel = channel; _cancelRecommendRetries(); if (widget.active) _scheduleRecommendData(); return; } _handleRecommendFeedState(); } void _handleRecommendFeedState() { if (!mounted || !widget.active) return; final feed = widget.feedController; if (feed.dramas.isNotEmpty) { _cancelRecommendRetries(); return; } if (feed.loaded && feed.lastError == null && !feed.isLoading && !_recommendRefreshInFlight) { _cancelRecommendRetries(); return; } if (feed.loaded && feed.lastError != null && !_recommendRefreshInFlight) { _scheduleRecommendRetry(); } } void _scheduleRecommendData() { if (_recommendDataRefreshScheduled) return; _recommendDataRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _recommendDataRefreshScheduled = false; if (!mounted || !widget.active) return; final feed = widget.feedController; if (feed.dramas.isEmpty || feed.lastError != null) { _recommendRetryAttempts = 0; if (!feed.isLoading && !_recommendRefreshInFlight) { if (feed.loaded) { _scheduleRecommendRetry(); } else { unawaited(_refreshRecommendData()); } } } final playlists = _readPlaylists(); if (playlists != null && !playlists.loaded && !playlists.loading) { unawaited(playlists.load()); } }); } void _scheduleRecommendRetry() { if (!mounted || !widget.active || _recommendRefreshInFlight || _recommendRetryTimer != null || _recommendRetryAttempts >= _maxRecommendRetries) { return; } setState(() { _recommendRetryTimer = Timer(_recommendRetryDelay, () { _recommendRetryTimer = null; if (!mounted || !widget.active) return; _recommendRetryAttempts += 1; unawaited(_refreshRecommendData()); }); }); } Future _refreshRecommendData() async { if (!mounted || !widget.active || _recommendRefreshInFlight) return; setState(() => _recommendRefreshInFlight = true); final succeeded = await widget.feedController.refreshCurrent(); if (!mounted) return; _recommendRefreshInFlight = false; if (!widget.active) return; final feed = widget.feedController; if (succeeded || (feed.loaded && feed.lastError == null)) { _cancelRecommendRetries(); setState(() {}); return; } setState(() {}); _scheduleRecommendRetry(); } void _cancelRecommendRetries() { _recommendRetryTimer?.cancel(); _recommendRetryTimer = null; _recommendRetryAttempts = 0; } @override void dispose() { widget.feedController.removeListener(_handleFeedControllerChanged); _cancelRecommendRetries(); _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: ListenableBuilder( listenable: _pageController, builder: (context, _) => RecommendTopNav( selected: _tab, pagePosition: _pageController.pagePosition, onVideo: _tab == RecommendInnerTab.feed, onSelected: _selectTab, onSearch: _handleSearch, onGift: widget.onOpenWallet, ), ), ), ), ], ), ); } Widget _recommendFeed(BuildContext context) { final feed = widget.feedController; final dramas = widget.feedController.dramas; if (dramas.isNotEmpty) { 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, // 内嵌流也是真实播放页:不传钱包/下载,锁定集点「解锁」会静默无响应, // 进全屏后也拿不到这两个依赖。 wallet: context.read(), downloads: context.read(), onOpenFullPlayer: (drama, episode) => _openDrama(drama, episodeId: episode.id), onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, onImmersiveChanged: widget.onImmersiveChanged, scrubCancelRequest: _scrubCancelRequest, ); } if (!feed.loaded || feed.isLoading || _recommendRefreshInFlight || _recommendRetryTimer != null) { return const _RecommendFeedSkeleton(); } final l10n = AppLocalizations.of(context)!; if (feed.lastError != null) { return ColoredBox( color: AppColors.mediaBlack, child: EmptyState( key: const Key('recommend-feed-empty'), icon: Icons.wifi_off_rounded, title: l10n.networkError, actionLabel: l10n.retry, actionKey: const Key('recommend-feed-retry'), onAction: _retryRecommendData, ), ); } return ColoredBox( color: AppColors.mediaBlack, child: EmptyState( key: const Key('recommend-feed-empty'), icon: Icons.movie_outlined, title: l10n.noStories, stretchContent: true, ), ); } void _retryRecommendData() { _cancelRecommendRetries(); unawaited(_refreshRecommendData()); } 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.followingGlow, AppColors.followingGlowMid, AppColors.appBackground, ], stops: [0, 0.14, 0.3], ), ), child: _withTopNavSpace( RecommendSectionsView( feedController: widget.feedController, categories: categories, onOpenDrama: _openDrama, onMore: _handleMore, playlists: _watchPlaylists(context), onOpenPlaylist: _openPlaylist, ), ), ); } void _selectTab(RecommendInnerTab tab) { setState(() { _tab = tab; _scrubCancelRequest++; }); unawaited( _pageController.selectPage( tab.index, reduceMotion: MediaQuery.disableAnimationsOf(context), ), ); } void _onPageChanged(int index) { if (!_pageController.acceptsSelection(index)) return; 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(), ), ), ), ); } Future _openDrama(Drama drama, {String? episodeId}) async { await playDramaFromCatalog(context, drama, episodeId: episodeId); } }