import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import '../../core/contracts/tmdb.dart'; import '../../providers/di_providers.dart'; import '../../providers/home_page_mode_provider.dart'; import '../../providers/shell_backdrop_provider.dart'; import '../../providers/sm_account_provider.dart'; import '../../providers/tmdb_settings_provider.dart'; import '../../shared/constants/breakpoints.dart'; import '../../shared/mappers/tmdb_image_url.dart'; import '../../shared/utils/color_utils.dart'; import '../../shared/utils/media_nav.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/hover_scrollable_row.dart'; import '../../shared/widgets/media_row_metrics.dart'; import '../../shared/widgets/media_poster_card.dart'; import '../../shared/widgets/scroll_fade_header.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/widgets/section_header.dart'; import '../../shared/widgets/skeleton_media_row.dart'; import '../../shared/widgets/tmdb_poster_card.dart'; import '../home/widgets/banner_carousel.dart' show HomeBannerSkeleton; import '../home/widgets/server_dropdown_button.dart'; import 'discover_helpers.dart'; import 'widgets/tmdb_banner_carousel.dart'; import 'widgets/trakt_continue_watching_row.dart'; String _smAccountHint(SmAuthStatus? status) => switch (status) { SmAuthStatus.expired => '登录已过期,请重新登录后再浏览推荐内容。', SmAuthStatus.degraded => '账号凭据不完整,请重新登录后再浏览推荐内容。', _ => '需要登录 sm-server 账号后才能浏览推荐内容。', }; class _DiscoverRowSpec { final String title; final String mediaType; final String sortBy; final String? withGenres; final String? voteCountGte; const _DiscoverRowSpec({ required this.title, required this.mediaType, this.sortBy = 'popularity.desc', this.withGenres, this.voteCountGte, }); TmdbDiscoverKey get key => ( mediaType: mediaType, sortBy: sortBy, withGenres: withGenres, voteCountGte: voteCountGte, ); } const _discoverRows = <_DiscoverRowSpec>[ _DiscoverRowSpec( title: '高分剧集', mediaType: 'tv', sortBy: 'vote_average.desc', voteCountGte: '200', ), _DiscoverRowSpec(title: '科幻电影', mediaType: 'movie', withGenres: '878'), _DiscoverRowSpec(title: '动画', mediaType: 'movie', withGenres: '16'), _DiscoverRowSpec(title: '真人秀', mediaType: 'tv', withGenres: '10764'), _DiscoverRowSpec(title: '纪录片', mediaType: 'movie', withGenres: '99'), ]; class DiscoverPage extends ConsumerStatefulWidget { final bool embedInHome; const DiscoverPage({super.key, this.embedInHome = false}); @override ConsumerState createState() => _DiscoverPageState(); } class _DiscoverPageState extends ConsumerState { TmdbRecommendation? _lastBannerItem; String? _lastBaseUrl; final ScrollController _scrollController = ScrollController(); final ValueNotifier _scrollProgress = ValueNotifier(0.0); @override void initState() { super.initState(); _scrollController.addListener(_handleScroll); } @override void dispose() { _scrollController.removeListener(_handleScroll); _scrollController.dispose(); _scrollProgress.dispose(); super.dispose(); } void _handleScroll() { if (!_scrollController.hasClients) return; const threshold = 120.0; final progress = (_scrollController.offset / threshold).clamp(0.0, 1.0); _scrollProgress.value = progress; } void _onBannerActiveItem(TmdbRecommendation item, String baseUrl) { _lastBannerItem = item; _lastBaseUrl = baseUrl; _writeBackdropIfActive(); } void _writeBackdropIfActive() { if (!_isActiveRoute()) return; final item = _lastBannerItem; final baseUrl = _lastBaseUrl; if (item == null || baseUrl == null) return; final url = TmdbImageUrl.backdrop(baseUrl, item.backdropPath); if (url == null) return; final state = ShellBackdropState( primaryImageUrl: url, fallbackImageUrls: [url], accentColor: _accentColorFor(item.id.toString()), ); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (!_isActiveRoute()) return; ref.read(shellBackdropProvider.notifier).show(state); }); } bool _isActiveRoute() { final location = ref.read(shellLocationProvider); if (location == '/discover') return true; return widget.embedInHome && location == '/' && ref.read(homePageModeProvider) == HomePageMode.recommend; } void _onNoBanner() { _lastBannerItem = null; _lastBaseUrl = null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (!_isActiveRoute()) return; if (ref.read(shellBackdropProvider) == null) return; ref.read(shellBackdropProvider.notifier).clear(); }); } Color _accentColorFor(String seed) => accentColorForSeed( seed, isDark: Theme.of(context).brightness == Brightness.dark, ); Future _refresh() async { ref.invalidate(tmdbTrendingDayProvider); ref.invalidate(tmdbTrendingWeekProvider); ref.invalidate(tmdbPopularMoviesProvider); ref.invalidate(tmdbPopularTvProvider); ref.invalidate(tmdbAiringTodayTvProvider); ref.invalidate(tmdbTopRatedMoviesProvider); for (final spec in _discoverRows) { ref.invalidate(tmdbDiscoverProvider(spec.key)); } ref.invalidate(traktContinueWatchingProvider); await Future.delayed(const Duration(milliseconds: 250)); } @override Widget build(BuildContext context) { final settingsAsync = ref.watch(tmdbSettingsProvider); ref.listen(shellLocationProvider, (_, loc) { if (loc != '/discover' && !(widget.embedInHome && loc == '/')) return; if (_lastBannerItem != null) { _writeBackdropIfActive(); } else { _onNoBanner(); } }); return Scaffold( backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, body: settingsAsync.when( loading: () { _onNoBanner(); return const _DiscoverSkeleton(); }, error: (_, _) { _onNoBanner(); return AppErrorState( title: 'TMDB 设置加载失败', onRetry: () => ref.invalidate(tmdbSettingsProvider), ); }, data: (settings) { final accountAsync = ref.watch(smAccountProvider); final account = accountAsync.value; final needsAccount = settings.accessMode == TmdbAccessMode.smAccount && settings.enabled; if (needsAccount && account == null && accountAsync.isLoading) { _onNoBanner(); return const _DiscoverSkeleton(); } final canRequest = settings.accessMode == TmdbAccessMode.smAccount ? settings.enabled && (account?.hasActiveSession ?? false) : settings.canRequest; if (!canRequest) { _onNoBanner(); final message = settings.accessMode == TmdbAccessMode.smAccount ? _smAccountHint(account?.status) : '需要在本地配置 TMDB API Key 或读取令牌后才能浏览推荐内容。'; return Center( child: EmptyState( icon: Icons.explore_off_outlined, title: 'TMDB 未配置', message: message, ), ); } final sections = [ _DiscoverSection( title: '今日热门', provider: tmdbTrendingDayProvider, imageBaseUrl: settings.effectiveImageBaseUrl, ), _DiscoverSection( title: '本周热门', provider: tmdbTrendingWeekProvider, imageBaseUrl: settings.effectiveImageBaseUrl, ), _DiscoverSection( title: '热门电影', provider: tmdbPopularMoviesProvider, imageBaseUrl: settings.effectiveImageBaseUrl, fallbackMediaType: 'movie', ), _DiscoverSection( title: '热门剧集', provider: tmdbPopularTvProvider, imageBaseUrl: settings.effectiveImageBaseUrl, fallbackMediaType: 'tv', ), _DiscoverSection( title: '高分电影', provider: tmdbTopRatedMoviesProvider, imageBaseUrl: settings.effectiveImageBaseUrl, fallbackMediaType: 'movie', ), for (final spec in _discoverRows) _DiscoverSection( title: spec.title, provider: tmdbDiscoverProvider(spec.key), imageBaseUrl: settings.effectiveImageBaseUrl, fallbackMediaType: spec.mediaType, ), ]; final tokens = context.appTokens; final isDesktop = Platform.isMacOS || Platform.isWindows; final isCompact = Breakpoints.isCompact( MediaQuery.sizeOf(context).width, ); final topPad = isDesktop ? tokens.titleBarHeight : math.max( MediaQuery.paddingOf(context).top, tokens.titleBarHeight, ); final listTopPad = isCompact ? 0.0 : topPad + 30; final listBottomPad = widget.embedInHome ? 116.0 : 32.0; return Stack( children: [ RefreshIndicator( onRefresh: _refresh, child: ListView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), padding: EdgeInsets.only( top: listTopPad, bottom: listBottomPad, ), children: [ Padding( padding: EdgeInsets.only( top: isCompact ? 0 : 12, bottom: 4, ), child: _DiscoverBanner( onActiveItem: _onBannerActiveItem, onNoBanner: _onNoBanner, ), ), const TraktContinueWatchingRow(), ...sections, const SizedBox(height: 24), ], ), ), Positioned( top: 0, left: 0, right: 0, child: ScrollFadeHeader( scrollProgress: _scrollProgress, actions: [ SizedBox( width: 38, height: 38, child: IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: '刷新', padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, onPressed: () => unawaited(_refresh()), ), ), ], ), ), if (widget.embedInHome) Positioned( top: MediaQuery.paddingOf(context).top + 8, left: 16, child: const ServerDropdownButton(), ), ], ); }, ), ); } } class _DiscoverSkeleton extends StatelessWidget { const _DiscoverSkeleton(); @override Widget build(BuildContext context) { final tokens = context.appTokens; final isDesktop = Platform.isMacOS || Platform.isWindows; final topPad = isDesktop ? tokens.titleBarHeight : math.max(MediaQuery.paddingOf(context).top, tokens.titleBarHeight); final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); final listTopPad = isCompact ? 0.0 : topPad + 30; return ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), padding: EdgeInsets.only(top: listTopPad, bottom: 32), children: [ Padding( padding: EdgeInsets.only(top: isCompact ? 0 : 12, bottom: 4), child: const HomeBannerSkeleton(), ), const SkeletonMediaRow( title: '继续观看', cardVariant: MediaCardVariant.landscape, titleColor: Colors.white, ), const SkeletonMediaRow(title: '今日热门', titleColor: Colors.white), const SkeletonMediaRow(title: '本周热门', titleColor: Colors.white), const SkeletonMediaRow(title: '热门电影', titleColor: Colors.white), const SkeletonMediaRow(title: '热门剧集', titleColor: Colors.white), ], ); } } class _DiscoverBanner extends ConsumerWidget { final void Function(TmdbRecommendation item, String baseUrl) onActiveItem; final VoidCallback onNoBanner; const _DiscoverBanner({required this.onActiveItem, required this.onNoBanner}); @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(tmdbSettingsProvider).value; final asyncData = ref.watch(tmdbAiringTodayTvProvider); return asyncData.when( loading: () { onNoBanner(); return const HomeBannerSkeleton(); }, error: (_, _) { onNoBanner(); return const SizedBox.shrink(); }, data: (res) { if (res == null || settings == null) { onNoBanner(); return const SizedBox.shrink(); } final items = res.results .where((e) => e.backdropPath?.isNotEmpty ?? false) .map((e) => e.mediaType == 'tv' ? e : e.copyWith(mediaType: 'tv')) .take(6) .toList(); if (items.isEmpty) { onNoBanner(); return const SizedBox.shrink(); } final baseUrl = settings.effectiveImageBaseUrl; return TmdbBannerCarousel( items: items, imageBaseUrl: baseUrl, onActiveItemChanged: (item) => onActiveItem(item, baseUrl), onItemTap: (item) => goTmdbDetail( context, tmdbId: item.id, mediaType: 'tv', seed: item, ), ); }, ); } } class _DiscoverSection extends ConsumerWidget { final String title; final ProviderListenable> provider; final String imageBaseUrl; final String? fallbackMediaType; const _DiscoverSection({ required this.title, required this.provider, required this.imageBaseUrl, this.fallbackMediaType, }); Widget _sectionTitle() => Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 4), child: SectionHeader(label: title, labelColor: Colors.white), ); @override Widget build(BuildContext context, WidgetRef ref) { final asyncData = ref.watch(provider); return asyncData.when( loading: () => SkeletonMediaRow(title: title, titleColor: Colors.white), error: (_, _) => const SizedBox.shrink(), data: (res) { if (res == null) return const SizedBox.shrink(); if (res.results.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionTitle(), const EmptyState( icon: Icons.movie_filter_outlined, title: '暂无内容', compact: true, ), ], ); } final items = res.results.length > 20 ? res.results.sublist(0, 20) : res.results; final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); final cardWidth = MediaRowMetrics.cardWidth( MediaCardVariant.poster, compact: compact, ); final rowHeight = cardWidth / 0.66 + 46.0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionTitle(), SizedBox( height: rowHeight, child: HoverScrollableRow( builder: (_, controller) => ListView.separated( controller: controller, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 24), itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(width: 14), itemBuilder: (_, i) { final item = items[i]; final type = item.mediaType ?? fallbackMediaType; final tappable = type == 'movie' || type == 'tv'; return TmdbPosterCard( key: ValueKey('$type:${item.id}'), title: item.title, posterPath: item.posterPath, voteAverage: item.voteAverage, mediaTypeLabel: _mediaTypeLabel(item.mediaType), imageBaseUrl: imageBaseUrl, width: cardWidth, textColor: Colors.white, onTap: tappable ? () => goTmdbDetail( context, tmdbId: item.id, mediaType: type!, seed: item, ) : null, ); }, ), ), ), ], ); }, ); } static String? _mediaTypeLabel(String? type) => mediaTypeLabel(type); }