import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/hub_vertical_navigation.dart'; import '../focus/locked_hub_controller.dart'; import '../i18n/strings.g.dart'; import '../media/ids.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../mixins/refreshable.dart'; import '../mixins/tab_visibility_aware.dart'; import '../models/catalog/catalog_item.dart'; import '../navigation/main_screen_scope.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/explore_provider.dart'; import '../services/catalog/catalog_source.dart'; import '../services/settings_service.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../widgets/app_icon.dart'; import '../widgets/app_menu.dart'; import '../widgets/catalog_source_logo.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hub_section.dart'; import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/settings_builder.dart'; import '../widgets/rasterized_gradient.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_scaffold.dart'; import 'catalog_search_screen.dart'; import 'libraries/state_messages.dart'; /// The Explore tab: watchlist + discover rows from the active external /// catalog source (Trakt). Only mounted when a source is connected (the tab /// is hidden otherwise, see [NavigationTab.getVisibleTabs]). class ExploreScreen extends StatefulWidget { const ExploreScreen({super.key}); @override State createState() => ExploreScreenState(); } class ExploreScreenState extends State with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab { late ExploreProvider _explore; /// Per-row focus keys, keyed by hub id so focus memory survives reloads. final Map> _hubKeysById = {}; List> _orderedHubKeys = const []; final _actionBarKey = GlobalKey(); final _sourceMenuKey = GlobalKey>(); final _tvBrowseRailKey = GlobalKey(); final _hubFocusMemory = HubFocusMemory(); final TvSpotlightController _spotlight = TvSpotlightController(); @override void initState() { super.initState(); _explore = context.read(); _explore.ensureFresh(); } @override void refresh() { _explore.ensureFresh(); } @override void fullRefresh() { unawaited(_explore.load()); } @override void onTabShown() { _explore.ensureFresh(); } @override void onTabHidden() {} @override void dispose() { _spotlight.dispose(); super.dispose(); } @override void focusActiveTabIfReady() { if (PlatformDetector.isTV()) { if (_explore.rowHubs.isNotEmpty) { _tvBrowseRailKey.currentState?.requestFocus(); } else { _actionBarKey.currentState?.requestFocusOnFirst(); } return; } _orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory(); } void _setSpotlightItem(MediaItem item) => _spotlight.select(item); void _updateHubKeys(List rowHubs) { final liveIds = {for (final rowHub in rowHubs) rowHub.hub.id}; _hubKeysById.removeWhere((id, _) => !liveIds.contains(id)); _orderedHubKeys = [ for (final rowHub in rowHubs) _hubKeysById.putIfAbsent(rowHub.hub.id, GlobalKey.new), ]; } bool _handleVerticalNavigation(int hubIndex, bool isUp) { final keys = _orderedHubKeys; return navigateVerticalHubRows( hubCount: keys.length, hubIndex: hubIndex, isUp: isUp, onTopBoundary: _actionBarKey.currentState?.requestFocusOnFirst, requestFocus: (targetIndex) { keys[targetIndex].currentState?.requestFocusFromMemory(); }, ); } void _navigateToSidebar() { MainScreenFocusScope.focusSidebarOf(context); } static IconData _rowIcon(CatalogRowId? row) => switch (row) { null => Symbols.thumb_up_rounded, CatalogRowId.watchlist => Symbols.bookmark_rounded, CatalogRowId.recommendedMovies || CatalogRowId.recommendedShows || CatalogRowId.suggestedAnime => Symbols.thumb_up_rounded, CatalogRowId.trendingMovies || CatalogRowId.trendingShows || CatalogRowId.trendingAnime || CatalogRowId.airingAnime || CatalogRowId.trending => Symbols.trending_up_rounded, CatalogRowId.popularMovies || CatalogRowId.popularShows || CatalogRowId.popularAnime => Symbols.whatshot_rounded, CatalogRowId.upcomingMovies || CatalogRowId.upcomingShows => Symbols.event_upcoming_rounded, }; List> _sourceMenuEntries(CatalogSourcesProvider sources, CatalogSource active) => [ for (final source in sources.connectedSources) AppMenuItem( value: source.id, leading: CatalogSourceLogo(source.id), label: source.displayName, selected: source.id == active.id, ), ]; Widget _buildSourceSwitcher( CatalogSourcesProvider sources, CatalogSource active, { TextStyle? textStyle, AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start, bool parentOwnsFocus = false, }) { final trigger = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: .min, children: [ CatalogSourceLogo(active.id, size: 22), const SizedBox(width: 8), Text(active.displayName, style: textStyle ?? Theme.of(context).textTheme.titleLarge), const SizedBox(width: 4), const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24), ], ), ); if (parentOwnsFocus) { return AppMenuButton( key: _sourceMenuKey, tooltip: t.explore.selectSource, anchorAlignment: anchorAlignment, onSelected: (id) => unawaited(sources.setActiveSource(id)), entriesBuilder: (context) => _sourceMenuEntries(sources, active), child: trigger, ); } return FocusablePopupMenuButton( menuKey: _sourceMenuKey, tooltip: t.explore.selectSource, semanticLabel: t.explore.selectSource, anchorAlignment: anchorAlignment, onSelected: (id) => unawaited(sources.setActiveSource(id)), itemBuilder: (context) => _sourceMenuEntries(sources, active), child: trigger, ); } /// App-bar title: the active source name, as a switcher dropdown when more /// than one source is connected (mirrors the libraries dropdown). Widget _buildTitle(CatalogSourcesProvider sources) { final active = sources.activeSource; if (active == null) return Text(t.explore.title); if (sources.connectedSources.length < 2) { return Text(active.displayName); } return _buildSourceSwitcher(sources, active); } @override Widget build(BuildContext context) { final explore = context.watch(); final sources = context.watch(); final rowHubs = explore.rowHubs; _updateHubKeys(rowHubs); // The TV toolbar must remain mounted for loading, error, and empty // sources so users can always switch away from a source with no rows. if (PlatformDetector.isTV()) { return SettingsBuilder( prefs: const [SettingsService.hideSpoilers, SettingsService.libraryDensity, SettingsService.episodePosterMode], builder: (context) => _buildTvContent(rowHubs, sources), ); } // One header mode for every state. Flipping floating/pinned between the // loading/empty scroll view and the content scroll view swaps the // SliverPersistentHeader variant (a different element type), which // reparents the GlobalKey'd action bar into a header that builds its // children during performLayout — and if a tooltip overlay is showing at // that moment (hover on refresh/search), its OverlayPortal re-activation // mutates the render tree mid-layout and asserts. Floating behaves // identically to pinned over the non-scrolling state widgets, so nothing // is lost by unifying. Widget appBar() => DesktopSliverAppBar( title: _buildTitle(sources), pinned: false, floating: true, snap: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ FocusableActionBar( key: _actionBarKey, onNavigateDown: () => _orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory(), actions: [ if (sources.activeSource case final CatalogSource source) FocusableAction( icon: Symbols.search_rounded, tooltip: t.common.search, onPressed: () => Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: source))), ), FocusableAction( icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: () => unawaited(_explore.load()), ), ], ), ], ); Widget buildSimpleScroll({required Widget body}) { return CustomScrollView( // Android clamping physics won't start a drag on non-filling // content, killing pull-to-refresh in the loading/empty/error // states without this. physics: const AlwaysScrollableScrollPhysics(), slivers: [ appBar(), SliverFillRemaining(child: body), ], ); } Widget content; if (rowHubs.isEmpty && explore.isLoading) { content = buildSimpleScroll(body: const Center(child: CircularProgressIndicator())); } else if (rowHubs.isEmpty && explore.state == ExploreLoadState.error) { content = buildSimpleScroll( body: ErrorStateWidget( message: explore.errorMessage ?? t.explore.emptyTitle, icon: Symbols.error_outline_rounded, onRetry: () => unawaited(_explore.load()), ), ); } else if (rowHubs.isEmpty) { content = buildSimpleScroll( body: EmptyStateWidget( message: t.explore.emptyMessage(source: explore.activeSource?.displayName ?? ''), icon: Symbols.explore_rounded, ), ); } else { content = CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ appBar(), for (var i = 0; i < rowHubs.length; i++) SliverToBoxAdapter( child: HubSection( key: _orderedHubKeys[i], hub: rowHubs[i].hub, focusMemory: _hubFocusMemory, icon: _rowIcon(rowHubs[i].row), loadMoreItems: rowHubs[i].hub.more ? () => _explore.loadAllForHub(rowHubs[i]) : null, onVerticalNavigation: (isUp) => _handleVerticalNavigation(i, isUp), onNavigateUp: i == 0 ? () => _actionBarKey.currentState?.requestFocusOnFirst() : null, onNavigateToSidebar: _navigateToSidebar, ), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ); } return Scaffold( body: RefreshIndicator(onRefresh: _explore.load, child: content), ); } ExploreRowHub? _rowForHub(MediaHub hub) { for (final rowHub in _explore.rowHubs) { if (rowHub.hub.id == hub.id) return rowHub; } return null; } Widget _buildTvToolbar(CatalogSourcesProvider sources) { final active = sources.activeSource; final statusBarHeight = MediaQuery.paddingOf(context).top; final colorScheme = Theme.of(context).colorScheme; final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; final foregroundColor = colorScheme.onSurface; return RasterizedGradient( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ overlayColor.withValues(alpha: 0.7), overlayColor.withValues(alpha: 0.5), overlayColor.withValues(alpha: 0.3), Colors.transparent, ], stops: const [0.0, 0.3, 0.6, 1.0], ), child: Padding( padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), child: Row( children: [ const Spacer(), FocusableActionBar( key: _actionBarKey, onNavigateLeft: _navigateToSidebar, onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, onBack: _navigateToSidebar, spacing: 4, actions: [ if (active != null && sources.connectedSources.length > 1) FocusableAction( debugLabel: 'ExploreSourceSwitcher', onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), child: _buildSourceSwitcher( sources, active, textStyle: Theme.of( context, ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), anchorAlignment: AppMenuAnchorAlignment.end, parentOwnsFocus: true, ), ), if (active != null) FocusableAction( icon: Symbols.search_rounded, iconColor: foregroundColor, tooltip: t.common.search, onPressed: () => Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), ), FocusableAction( icon: Symbols.refresh_rounded, iconColor: foregroundColor, tooltip: t.common.refresh, onPressed: () => unawaited(_explore.load()), ), ], ), ], ), ), ); } Widget _buildTvContent(List rowHubs, CatalogSourcesProvider sources) { final tvHubs = [for (final rowHub in rowHubs) rowHub.hub]; final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: tvHubs, spotlightListenable: _spotlight, resolveSpotlight: () => _spotlight.resolve(tvHubs), resolveClient: (spotlight) => context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId)), foreground: Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ if (tvHubs.isEmpty && _explore.isLoading) const Center(child: CircularProgressIndicator()) else if (tvHubs.isEmpty && _explore.state == ExploreLoadState.error) Center( child: ErrorStateWidget( message: _explore.errorMessage ?? t.explore.emptyTitle, icon: Symbols.error_outline_rounded, onRetry: () => unawaited(_explore.load()), ), ) else if (tvHubs.isEmpty) Center( child: EmptyStateWidget( message: t.explore.emptyMessage(source: _explore.activeSource?.displayName ?? ''), icon: Symbols.explore_rounded, ), ), if (tvHubs.isNotEmpty) Positioned( left: 0, right: 0, bottom: 0, child: TvBrowseRail( key: _tvBrowseRailKey, hubs: tvHubs, focusMemory: _hubFocusMemory, iconForHub: (hub, _) => _rowIcon(_rowForHub(hub)?.row), onFocusedItemChanged: _setSpotlightItem, loadMoreItems: (hub) { final rowHub = _rowForHub(hub); return rowHub == null ? Future.value(hub.items) : _explore.loadAllForHub(rowHub); }, onNavigateUp: _actionBarKey.currentState?.requestFocusOnFirst, onNavigateToSidebar: _navigateToSidebar, onBack: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ), ), Builder( builder: (context) => SideNavigationBleedBuilder( targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), child: ExcludeFocusTraversal(child: _buildTvToolbar(sources)), builder: (context, animatedBleed, child) => Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), ), ), ], ), ); } }