Files
plezy/lib/screens/explore_screen.dart
T

457 lines
17 KiB
Dart

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 '../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<ExploreScreen> createState() => ExploreScreenState();
}
class ExploreScreenState extends State<ExploreScreen>
with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab {
late ExploreProvider _explore;
/// Per-row focus keys, keyed by hub id so focus memory survives reloads.
final Map<String, GlobalKey<HubSectionState>> _hubKeysById = {};
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
final _actionBarKey = GlobalKey<FocusableActionBarState>();
final _sourceMenuKey = GlobalKey<AppMenuButtonState<CatalogSourceId>>();
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
final TvSpotlightController _spotlight = TvSpotlightController();
@override
void initState() {
super.initState();
_explore = context.read<ExploreProvider>();
_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<ExploreRowHub> rowHubs) {
final liveIds = <String>{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<HubSectionState>.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) {
CatalogRowId.watchlist => Symbols.bookmark_rounded,
CatalogRowId.recommendedMovies ||
CatalogRowId.recommendedShows ||
CatalogRowId.suggestedAnime => Symbols.thumb_up_rounded,
CatalogRowId.trendingMovies ||
CatalogRowId.trendingShows ||
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<AppMenuEntry<CatalogSourceId>> _sourceMenuEntries(CatalogSourcesProvider sources, CatalogSource active) => [
for (final source in sources.connectedSources)
AppMenuItem<CatalogSourceId>(
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<CatalogSourceId>(
key: _sourceMenuKey,
tooltip: t.explore.selectSource,
anchorAlignment: anchorAlignment,
onSelected: (id) => unawaited(sources.setActiveSource(id)),
entriesBuilder: (context) => _sourceMenuEntries(sources, active),
child: trigger,
);
}
return FocusablePopupMenuButton<CatalogSourceId>(
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<ExploreProvider>();
final sources = context.watch<CatalogSourcesProvider>();
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<void>(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,
icon: _rowIcon(rowHubs[i].row),
loadMoreItems: rowHubs[i].hub.more ? () => _explore.loadAllForRow(rowHubs[i].row) : 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),
);
}
CatalogRowId? _rowForHub(MediaHub hub) {
for (final rowHub in _explore.rowHubs) {
if (rowHub.hub.id == hub.id) return rowHub.row;
}
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<void>(builder: (_) => CatalogSearchScreen(source: active))),
),
FocusableAction(
icon: Symbols.refresh_rounded,
iconColor: foregroundColor,
tooltip: t.common.refresh,
onPressed: () => unawaited(_explore.load()),
),
],
),
],
),
),
);
}
Widget _buildTvContent(List<ExploreRowHub> 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,
iconForHub: (hub, _) => _rowIcon(_rowForHub(hub) ?? CatalogRowId.watchlist),
onFocusedItemChanged: _setSpotlightItem,
loadMoreItems: (hub) {
final row = _rowForHub(hub);
return row == null ? Future.value(hub.items) : _explore.loadAllForRow(row);
},
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!),
),
),
],
),
);
}
}