import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import 'package:go_router/go_router.dart'; import '../../core/contracts/auth.dart'; import '../../core/contracts/library.dart'; import '../../providers/di_providers.dart'; import '../../providers/emby_headers_provider.dart'; import '../../providers/home_banner_provider.dart'; import '../../providers/home_page_mode_provider.dart'; import '../../providers/library_overview_provider.dart'; import '../../providers/playback_active_provider.dart'; import '../../providers/server_provider.dart'; import '../../providers/session_provider.dart'; import '../../providers/shell_backdrop_provider.dart'; import '../../providers/tmdb_prefetch_provider.dart'; import '../../shared/mappers/media_image_url.dart'; import '../../shared/utils/color_utils.dart'; import '../../shared/utils/user_error_formatter.dart'; import '../../shared/widgets/add_server_modal.dart'; import '../../shared/widgets/app_dialog.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/error_media_row.dart'; import '../../shared/utils/media_nav.dart'; import '../../shared/widgets/frosted_panel.dart'; import '../../shared/widgets/latest_media_row.dart'; import '../../shared/widgets/login_form.dart'; import '../../shared/widgets/media_poster_card.dart'; import '../../shared/constants/breakpoints.dart'; import '../../shared/widgets/scroll_fade_header.dart'; import '../../shared/widgets/skeleton_media_row.dart'; import '../discover/discover_page.dart'; import 'widgets/banner_carousel.dart'; import 'widgets/server_dropdown_button.dart'; class HomePage extends ConsumerWidget { const HomePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(activeSessionProvider, (previous, next) { if (previous != null && next == null) { ref.read(shellBackdropProvider.notifier).clear(); } }); final sessionAsync = ref.watch(sessionProvider); if (sessionAsync.isLoading && !sessionAsync.hasValue) { return const Scaffold( backgroundColor: Colors.transparent, body: SizedBox.shrink(), ); } final session = ref.watch(activeSessionProvider); if (session == null) return const _UnauthenticatedHome(); return _AuthenticatedHome(session: session); } } class _UnauthenticatedHome extends ConsumerWidget { const _UnauthenticatedHome(); @override Widget build(BuildContext context, WidgetRef ref) { final servers = ref.watch(serverListProvider); return Scaffold( backgroundColor: Colors.transparent, body: servers.when( data: (list) { final availableList = list.where((s) => !s.paused).toList(); if (list.isEmpty || availableList.isEmpty) { return Center( child: EmptyState( icon: Icons.cloud_outlined, title: list.isEmpty ? '尚未添加 Emby 服务器' : '所有服务器已暂停', message: list.isEmpty ? '添加第一个服务器,开始构建你的影库。' : '前往「服务器」页恢复使用,或添加新服务器。', action: FButton( variant: FButtonVariant.primary, onPress: () => showAddServerModal(context), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add, size: 16), SizedBox(width: 6), Text('添加服务器'), ], ), ), ), ); } return Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 540), child: FrostedPanel( radius: 32, padding: const EdgeInsets.all(28), color: Theme.of( context, ).colorScheme.surface.withValues(alpha: 0.72), border: Border.all( color: Colors.white.withValues(alpha: 0.22), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '选择服务器登录', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( '所有内容都会以当前服务器为中心进行同步与推荐。', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 18), ...availableList.map( (server) => Padding( padding: const EdgeInsets.only(bottom: 10), child: FCard( child: ListTile( leading: const Icon(Icons.dns_outlined), title: Text(server.name), subtitle: Text(server.baseUrl), onTap: () async { await showAppRawDialog( context, constraints: const BoxConstraints( maxWidth: 460, ), builder: (_) => Padding( padding: const EdgeInsets.all(24), child: LoginForm( server: server, onBack: () => Navigator.pop(context), onSuccess: () => Navigator.pop(context), ), ), ); }, ), ), ), ), const SizedBox(height: 8), FButton( variant: FButtonVariant.outline, onPress: () => showAddServerModal(context), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add, size: 16), SizedBox(width: 6), Text('添加新服务器'), ], ), ), ], ), ), ), ), ); }, loading: () => const Center(child: AppLoadingRing()), error: (error, _) => AppErrorState( message: formatUserError(error, fallback: '登录态加载失败'), onRetry: () => ref.invalidate(sessionProvider), ), ), ); } } class _AuthenticatedHome extends ConsumerStatefulWidget { final AuthedSession session; const _AuthenticatedHome({required this.session}); @override ConsumerState<_AuthenticatedHome> createState() => _AuthenticatedHomeState(); } class _AuthenticatedHomeState extends ConsumerState<_AuthenticatedHome> { final Map _favoriteMap = {}; final Map _playedMap = {}; final Map _favoriteLoadingMap = {}; final Map _playedLoadingMap = {}; final Map _hideResumeLoadingMap = {}; final Set _hiddenResumeIds = {}; 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; } Future _toggleFavorite(EmbyRawItem item) async { if (_favoriteLoadingMap[item.Id] == true) return; final next = !(_favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false); setState(() => _favoriteLoadingMap[item.Id] = true); try { final useCase = await ref.read(setFavoriteUseCaseProvider.future); final result = await useCase.execute( FavoriteSetReq(itemId: item.Id, isFavorite: next), ); setState(() => _favoriteMap[item.Id] = result.isFavorite); } finally { setState(() => _favoriteLoadingMap[item.Id] = false); } } Future _togglePlayed(EmbyRawItem item) async { if (_playedLoadingMap[item.Id] == true) return; final next = !(_playedMap[item.Id] ?? item.UserData?.Played ?? false); setState(() => _playedLoadingMap[item.Id] = true); try { final useCase = await ref.read(setPlayedUseCaseProvider.future); final result = await useCase.execute( PlayedSetReq(itemId: item.Id, played: next), ); setState(() => _playedMap[item.Id] = result.played); unawaited(_syncTraktPlayedChange(item, result.played)); } finally { setState(() => _playedLoadingMap[item.Id] = false); } } Future _syncTraktPlayedChange(EmbyRawItem item, bool played) async { try { final service = await ref.read(traktSyncServiceProvider.future); await service.syncEmbyPlayedChange(item: item, played: played); } catch (_) {} } Future _hideFromResume(EmbyRawItem item) async { if (_hideResumeLoadingMap[item.Id] == true) return; setState(() { _hideResumeLoadingMap[item.Id] = true; _hiddenResumeIds.add(item.Id); }); try { final useCase = await ref.read(hideFromResumeUseCaseProvider.future); final result = await useCase.execute( HideFromResumeReq(itemId: item.Id, hide: true), ); if (!result.hide) { setState(() => _hiddenResumeIds.remove(item.Id)); } } catch (_) { setState(() => _hiddenResumeIds.remove(item.Id)); } finally { setState(() => _hideResumeLoadingMap[item.Id] = false); } } bool _resolveFavorite(EmbyRawItem item) { return _favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false; } bool _resolvePlayed(EmbyRawItem item) { return _playedMap[item.Id] ?? item.UserData?.Played ?? false; } Map _buildFavoriteMap(List items) { return {for (final item in items) item.Id: _resolveFavorite(item)}; } Map _buildPlayedMap(List items) { return {for (final item in items) item.Id: _resolvePlayed(item)}; } void _openMediaDetail(EmbyRawItem item) { goMediaDetail(context, item, ref: ref); } EmbyRawItem? _lastBannerItem; bool _isHomeForeground() { return ref.read(shellLocationProvider) == '/' && !ref.read(detailPageActiveProvider); } void _updateShellBackdrop(EmbyRawItem item) { _lastBannerItem = item; if (!_isHomeForeground()) return; final backdropUrl = EmbyImageUrl.detailBanner( baseUrl: widget.session.serverUrl, token: widget.session.token, item: item, ); final fallbackId = (item.SeriesId?.isNotEmpty ?? false) ? item.SeriesId! : item.Id; final fallbackUrls = { EmbyImageUrl.fanart( baseUrl: widget.session.serverUrl, token: widget.session.token, itemId: fallbackId, ), backdropUrl, }.toList(); final accentColor = _accentColorFor(item.Id); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (!_isHomeForeground()) return; ref .read(shellBackdropProvider.notifier) .show( ShellBackdropState( primaryImageUrl: backdropUrl, fallbackImageUrls: fallbackUrls, accentColor: accentColor, ), ); }); } void _clearShellBackdropDeferred() { _lastBannerItem = null; if (ref.read(shellBackdropProvider) == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (!_isHomeForeground()) return; ref.read(shellBackdropProvider.notifier).clear(); }); } Color _accentColorFor(String seed) => accentColorForSeed( seed, isDark: Theme.of(context).brightness == Brightness.dark, ); @override Widget build(BuildContext context) { final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; final mode = ref.watch(homePageModeProvider); if (isCompact && mode == HomePageMode.recommend) { return const DiscoverPage(embedInHome: true); } final state = ref.watch(libraryOverviewProvider); final bannerAsync = ref.watch(homeBannerProvider); final imageHeaders = ref.watch(embyImageHeadersProvider).value; final dpr = MediaQuery.devicePixelRatioOf(context); final homeForeground = ref.watch(shellLocationProvider) == '/' && !ref.watch(detailPageActiveProvider); ref.watch(tmdbPrefetchProvider); ref.listen(shellLocationProvider, (_, loc) { if (loc != '/') return; if (ref.read(detailPageActiveProvider)) return; if (_lastBannerItem != null) { _updateShellBackdrop(_lastBannerItem!); } else { _clearShellBackdropDeferred(); } }); ref.listen(detailPageActiveProvider, (_, active) { if (active) return; if (ref.read(shellLocationProvider) != '/') return; ref.read(libraryOverviewProvider.notifier).softRefresh(); if (_lastBannerItem != null) { _updateShellBackdrop(_lastBannerItem!); } else { _clearShellBackdropDeferred(); } }); return Scaffold( backgroundColor: Colors.transparent, body: state.when( data: (data) { if (data.libraries.isEmpty) { _clearShellBackdropDeferred(); return const Center( child: EmptyState( icon: Icons.movie_filter_outlined, title: '没有可用的媒体库', ), ); } final resumeItems = data.resumeItems .where((item) => !_hiddenResumeIds.contains(item.Id)) .toList(); return Stack( children: [ RefreshIndicator( onRefresh: () => Future.wait([ ref.read(libraryOverviewProvider.notifier).softRefresh(), ref.read(homeBannerProvider.notifier).softRefresh(), ]), displacement: 72, child: ListView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), padding: EdgeInsets.only(top: isCompact ? 0 : 60, bottom: 32), children: [ bannerAsync.when( data: (bannerItems) { if (bannerItems.isEmpty) { _clearShellBackdropDeferred(); return const SizedBox.shrink( key: ValueKey('home-banner-empty'), ); } return Padding( key: const ValueKey('home-banner'), padding: const EdgeInsets.only(bottom: 16), child: HomeBannerCarousel( baseUrl: widget.session.serverUrl, token: widget.session.token, items: bannerItems, imageHeaders: imageHeaders, onItemTap: _openMediaDetail, onActiveItemChanged: _updateShellBackdrop, active: homeForeground, ), ); }, loading: () => const Padding( key: ValueKey('home-banner-loading'), padding: EdgeInsets.only(bottom: 16), child: HomeBannerSkeleton(), ), error: (_, _) { _clearShellBackdropDeferred(); return const SizedBox.shrink( key: ValueKey('home-banner-error'), ); }, ), if (data.resumeLoading) const SkeletonMediaRow( key: ValueKey('home-resume-row'), title: '继续观看', cardVariant: MediaCardVariant.landscape, titleColor: Colors.white, ) else if (data.resumeError != null) ErrorMediaRow( key: const ValueKey('home-resume-row'), title: '继续观看', cardVariant: MediaCardVariant.landscape, titleColor: Colors.white, onRetry: () => ref .read(libraryOverviewProvider.notifier) .retryResume(), ) else if (resumeItems.isNotEmpty) LatestMediaRow( key: const ValueKey('home-resume-row'), title: '继续观看', items: resumeItems, cardVariant: MediaCardVariant.landscape, enableContextMenu: true, favoriteMap: _buildFavoriteMap(resumeItems), playedMap: _buildPlayedMap(resumeItems), favoriteLoadingMap: _favoriteLoadingMap, playedLoadingMap: _playedLoadingMap, onAddFavorite: _toggleFavorite, onMarkPlayed: _togglePlayed, onHideFromResume: _hideFromResume, hideFromResumeLoadingMap: _hideResumeLoadingMap, imageHeaders: imageHeaders, titleColor: Colors.white, imageUrlBuilder: (item) => EmbyImageUrl.landscapeCard( baseUrl: widget.session.serverUrl, token: widget.session.token, item: item, maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr), ), preloadImageUrlBuilder: (item) => EmbyImageUrl.detailBanner( baseUrl: widget.session.serverUrl, token: widget.session.token, item: item, ), onItemTap: _openMediaDetail, ), if (data.libraries.isNotEmpty) LatestMediaRow( key: const ValueKey('home-library-row'), title: '我的媒体列表', items: data.libraries .map( (library) => EmbyRawItem( Id: library.Id, Name: library.Name, PrimaryImageTag: library.ImageTags?['Primary'], ImageTags: library.ImageTags, ), ) .toList(), cardVariant: MediaCardVariant.landscape, showHoverPlayIcon: false, imageHeaders: imageHeaders, titleColor: Colors.white, imageUrlBuilder: (item) => EmbyImageUrl.primary( baseUrl: widget.session.serverUrl, token: widget.session.token, item: item, maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr), ), onItemTap: (item) => context.push('/library/${item.Id}'), ), ...data.libraries.expand((library) { final isLoading = data.loadingLibraryIds.contains( library.Id, ); if (isLoading) { return [ SkeletonMediaRow( key: ValueKey('home-latest-row-${library.Id}'), title: '${library.Name} · 最新更新', titleColor: Colors.white, ), ]; } final error = data.latestErrorByLibrary[library.Id]; if (error != null) { return [ ErrorMediaRow( key: ValueKey('home-latest-row-${library.Id}'), title: '${library.Name} · 最新更新', titleColor: Colors.white, onRetry: () => ref .read(libraryOverviewProvider.notifier) .retryLatest(library.Id), ), ]; } final items = data.latestByLibrary[library.Id] ?? const []; if (items.isEmpty) return const []; return [ LatestMediaRow( key: ValueKey('home-latest-row-${library.Id}'), title: library.Name, subtitle: '最新更新', items: items, totalCount: data.totalCountByLibrary[library.Id], enableContextMenu: true, favoriteMap: _buildFavoriteMap(items), playedMap: _buildPlayedMap(items), favoriteLoadingMap: _favoriteLoadingMap, playedLoadingMap: _playedLoadingMap, onAddFavorite: _toggleFavorite, onMarkPlayed: _togglePlayed, imageHeaders: imageHeaders, titleColor: Colors.white, imageUrlBuilder: (item) => EmbyImageUrl.primary( baseUrl: widget.session.serverUrl, token: widget.session.token, item: item, maxWidth: EmbyImageUrl.targetPixelWidth(158, dpr), ), preloadImageUrlBuilder: (item) => EmbyImageUrl.detailBanner( baseUrl: widget.session.serverUrl, token: widget.session.token, item: item, ), onItemTap: _openMediaDetail, onSeeAll: () => context.push('/library/${library.Id}'), ), ]; }), ], ), ), if (MediaQuery.sizeOf(context).width < Breakpoints.compact) Positioned( top: MediaQuery.paddingOf(context).top + 8, left: 16, child: const ServerDropdownButton(), ) else Positioned( top: 0, left: 0, right: 0, child: ScrollFadeHeader(scrollProgress: _scrollProgress), ), ], ); }, loading: () => const Center(child: AppLoadingRing()), error: (error, _) => AppErrorState( message: formatUserError(error, fallback: '首页内容加载失败'), panel: true, onRetry: () => ref.read(libraryOverviewProvider.notifier).refresh(), ), ), ); } }