diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index 58152bc0..f98f8347 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -7,6 +7,9 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; +import '../focus/dpad_navigator.dart'; +import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; @@ -20,11 +23,13 @@ import '../utils/app_logger.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/formatters.dart'; import '../utils/media_navigation_helper.dart'; +import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/app_bar_back_button.dart'; import '../widgets/app_icon.dart'; import '../widgets/backend_badge.dart'; import '../widgets/cast_member_strip.dart'; +import '../widgets/focusable_list_tile.dart'; import '../widgets/hub_section.dart'; import '../widgets/optimized_media_image.dart'; import '../widgets/overlay_sheet.dart'; @@ -47,6 +52,11 @@ class CatalogItemDetailScreen extends StatefulWidget { class _CatalogItemDetailScreenState extends State { final _actionBarKey = GlobalKey(); + final _castSectionKey = GlobalKey(); + final _castStripKey = GlobalKey(); + final _relatedSectionKey = GlobalKey(); + final ScrollController _scrollController = ScrollController(); + List _libraryMatchFocusNodes = const []; CatalogSource? _watchlistSource; SeerrCatalogSource? _requestSource; bool _mutatingWatchlist = false; @@ -92,6 +102,10 @@ class _CatalogItemDetailScreenState extends State { @override void dispose() { _watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged); + for (final node in _libraryMatchFocusNodes) { + node.dispose(); + } + _scrollController.dispose(); super.dispose(); } @@ -102,14 +116,28 @@ class _CatalogItemDetailScreenState extends State { Future _resolveMatches() async { try { - final matches = await context.read().match(widget.item); - if (mounted) setState(() => _matches = matches); + _setMatches(await context.read().match(widget.item)); } catch (e) { appLogger.w('Catalog library match failed for ${widget.item.identityKey}', error: e); - if (mounted) setState(() => _matches = const []); + _setMatches(const []); } } + void _setMatches(List matches) { + if (!mounted) return; + for (final node in _libraryMatchFocusNodes) { + node.dispose(); + } + _libraryMatchFocusNodes = [ + for (var index = 0; index < matches.length; index++) + FocusNode( + debugLabel: 'catalog_library_match_$index', + onKeyEvent: (node, event) => _handleLibraryMatchKey(index, event), + ), + ]; + setState(() => _matches = matches); + } + CatalogSource? get _ownSource => context.read().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source); @@ -139,6 +167,119 @@ class _CatalogItemDetailScreenState extends State { } } + bool get _hasActions => _watchlistSource != null || _requestSource != null; + + bool get _hasLibraryMatches => _libraryMatchFocusNodes.isNotEmpty; + + void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) { + final focusContext = node?.context; + if (focusContext == null) return; + unawaited( + Scrollable.ensureVisible( + focusContext, + alignment: alignment, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ), + ); + } + + void _requestLibraryMatchFocus(int index) { + if (index < 0 || index >= _libraryMatchFocusNodes.length) return; + final node = _libraryMatchFocusNodes[index]; + node.requestFocus(); + _revealFocusNode(node); + } + + bool _focusSectionBelowLibraryMatches() { + if (_cast?.isNotEmpty ?? false) { + _requestCastFocus(); + return true; + } + if (_related?.isNotEmpty ?? false) { + _requestRelatedFocus(); + return true; + } + return false; + } + + KeyEventResult _handleLibraryMatchKey(int index, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key.isUpKey) { + if (index > 0) { + _requestLibraryMatchFocus(index - 1); + } else if (_hasActions) { + _requestActionBarFocus(); + } else { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (index + 1 < _libraryMatchFocusNodes.length) { + _requestLibraryMatchFocus(index + 1); + } else if (!_focusSectionBelowLibraryMatches()) { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _requestCastFocus() { + if (!(_cast?.isNotEmpty ?? false)) return; + _castStripKey.currentState?.requestFocus(); + final sectionContext = _castSectionKey.currentContext; + if (sectionContext == null) return; + unawaited( + Scrollable.ensureVisible( + sectionContext, + alignment: 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ), + ); + } + + void _requestActionBarFocus() { + _actionBarKey.currentState?.requestFocusOnFirst(); + if (!_scrollController.hasClients) return; + unawaited(_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut)); + } + + void _requestRelatedFocus() { + _relatedSectionKey.currentState?.requestFocusFromMemory(); + } + + void _focusSectionBelowActions() { + if (_hasLibraryMatches) { + _requestLibraryMatchFocus(0); + } else if (_cast?.isNotEmpty ?? false) { + _requestCastFocus(); + } else { + _requestRelatedFocus(); + } + } + + void _focusSectionAboveCast() { + if (_hasLibraryMatches) { + _requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1); + } else { + _requestActionBarFocus(); + } + } + + void _focusSectionAboveRelated() { + if (_cast?.isNotEmpty ?? false) { + _requestCastFocus(); + } else if (_hasLibraryMatches) { + _requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1); + } else { + _requestActionBarFocus(); + } + } + bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(widget.item.kind, widget.item.ids); Future _toggleWatchlist() async { @@ -159,6 +300,21 @@ class _CatalogItemDetailScreenState extends State { } } + Widget _buildLibraryMatchTile(MediaItem match, int index) { + return FocusableListTile( + focusNode: _libraryMatchFocusNodes[index], + dense: false, + visualDensity: VisualDensity.standard, + leading: BackendBadge(backend: match.backend, size: 24), + // Plex matches carry their library title; Jellyfin's search-based + // lookup doesn't, so fall back to the server name alone. + title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name), + subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null, + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => unawaited(navigateToMediaItemDetails(context, match)), + ); + } + /// Library availability, resolved in place: a progress row while the /// matcher runs, "Not in your library" when nothing matched, otherwise an /// "In these libraries" list whose rows open the normal media detail @@ -193,23 +349,11 @@ class _CatalogItemDetailScreenState extends State { Text(t.explore.inTheseLibraries, style: theme.textTheme.titleMedium), const SizedBox(height: 12), // M3E grouped cards, same row anatomy as the settings/trackers hub: - // server-type logo leading, name, chevron trailing. The tiles' native - // ink highlight inside SettingsGroup's shaped Material is the d-pad - // focus visual. + // server-type logo leading, name, chevron trailing. SettingsGroup( margin: EdgeInsets.zero, children: [ - for (final match in matches) - ListTile( - leading: BackendBadge(backend: match.backend, size: 24), - // Plex matches carry their library title; Jellyfin's - // search-based lookup doesn't, so fall back to the server - // name alone (the badge already shows the server type). - title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name), - subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null, - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => unawaited(navigateToMediaItemDetails(context, match)), - ), + for (var index = 0; index < matches.length; index++) _buildLibraryMatchTile(matches[index], index), ], ), ], @@ -260,6 +404,7 @@ class _CatalogItemDetailScreenState extends State { /// characters with their role, so the section is titled accordingly. Widget _buildCastSection(ThemeData theme, List cast) { return Column( + key: _castSectionKey, crossAxisAlignment: .start, children: [ Text( @@ -268,9 +413,13 @@ class _CatalogItemDetailScreenState extends State { ), const SizedBox(height: 4), CastMemberStrip( + key: _castStripKey, members: [ for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl), ], + onNavigateUp: _hasLibraryMatches || _hasActions ? _focusSectionAboveCast : null, + onNavigateDown: (_related?.isNotEmpty ?? false) ? _requestRelatedFocus : null, + debugLabel: 'catalog_cast_row', ), ], ); @@ -281,6 +430,7 @@ class _CatalogItemDetailScreenState extends State { /// the Explore rows (tap opens another catalog detail screen). Widget _buildRelatedSection(List related) { return HubSection( + key: _relatedSectionKey, hub: MediaHub( id: 'catalog-related:${widget.item.source.name}:${widget.item.identityKey}', identifier: 'explore.related', @@ -291,6 +441,8 @@ class _CatalogItemDetailScreenState extends State { ), icon: Symbols.recommend_rounded, inset: true, + onNavigateUp: _focusSectionAboveRelated, + cardSizing: HubCardSizing.grid, ); } @@ -301,151 +453,158 @@ class _CatalogItemDetailScreenState extends State { final onWatchlist = _isOnWatchlist; final viewInsets = MediaQuery.paddingOf(context); - // The request sheet uses OverlaySheetController.showAdaptive; the host - // keeps it dpad-safe on TV, and canPop opts into its PopScope so a - // system back closes an open sheet instead of popping this screen. + final blockSystemBack = PlatformDetector.isTV() || InputModeTracker.shouldBlockSystemBack(context); + // Match the established detail-screen back policy: TV/keyboard back is + // owned by the focus tree, while native mobile back and iOS swipe-back + // remain route-driven. The overlay host always gets first refusal. return OverlaySheetHost( - canPop: true, - child: Scaffold( - body: Stack( - children: [ - SingleChildScrollView( - // The backdrop lives inside the scrollable so it moves with - // the content (it extends under the status bar, so the safe - // areas are baked into the content padding instead of a - // SafeArea around the scroll view). - child: Stack( - children: [ - if (item.backdropUrl != null) - Positioned( - top: 0, - left: 0, - right: 0, - height: 320, - child: ShaderMask( - shaderCallback: (rect) => LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.black, Colors.black.withValues(alpha: 0.0)], - stops: const [0.3, 1.0], - ).createShader(rect), - blendMode: BlendMode.dstIn, - child: OptimizedMediaImage.thumb( - imagePath: item.backdropUrl, - width: double.infinity, - height: 320, - fit: BoxFit.cover, - fallbackIcon: null, + canPop: !blockSystemBack, + child: Focus( + onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), + child: Scaffold( + body: Stack( + children: [ + SingleChildScrollView( + key: const Key('catalog_detail_scroll'), + controller: _scrollController, + // The backdrop lives inside the scrollable so it moves with + // the content (it extends under the status bar, so the safe + // areas are baked into the content padding instead of a + // SafeArea around the scroll view). + child: Stack( + children: [ + if (item.backdropUrl != null) + Positioned( + top: 0, + left: 0, + right: 0, + height: 320, + child: ShaderMask( + shaderCallback: (rect) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black, Colors.black.withValues(alpha: 0.0)], + stops: const [0.3, 1.0], + ).createShader(rect), + blendMode: BlendMode.dstIn, + child: OptimizedMediaImage.thumb( + imagePath: item.backdropUrl, + width: double.infinity, + height: 320, + fit: BoxFit.cover, + fallbackIcon: null, + ), ), ), - ), - Padding( - padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32), - child: Column( - crossAxisAlignment: .start, - children: [ - Row( - crossAxisAlignment: .start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210), - ), - const SizedBox(width: 20), - Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text( - item.title, - style: theme.textTheme.headlineMedium, - maxLines: 3, - overflow: .ellipsis, - ), - if (_metaLine.isNotEmpty) ...[ - const SizedBox(height: 8), + Padding( + padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + crossAxisAlignment: .start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ Text( - _metaLine, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), - ), + item.title, + style: theme.textTheme.headlineMedium, + maxLines: 3, + overflow: .ellipsis, ), - ], - if (item.genres?.isNotEmpty ?? false) ...[ - const SizedBox(height: 8), - Text( - item.genres!.join(' • '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + if (_metaLine.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _metaLine, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), ), - ), - ], - const SizedBox(height: 16), - if (_watchlistSource != null || _requestSource != null) - FocusableActionBar( - key: _actionBarKey, - actions: [ - if (_watchlistSource != null) - FocusableAction( - icon: onWatchlist ?? false - ? Symbols.bookmark_added_rounded - : Symbols.bookmark_add_rounded, - tooltip: onWatchlist ?? false - ? t.explore.removeFromWatchlist - : t.explore.addToWatchlist, - onPressed: onWatchlist == null - ? () {} - : () => unawaited(_toggleWatchlist()), - ), - if (_requestSource case final SeerrCatalogSource seerr) - FocusableAction( - icon: Symbols.download_rounded, - tooltip: t.seerr.request, - onPressed: () => unawaited( - showSeerrRequestSheet( - context, - source: seerr, - kind: item.kind, - tmdbId: item.ids.tmdb!, - title: item.title, + ], + if (item.genres?.isNotEmpty ?? false) ...[ + const SizedBox(height: 8), + Text( + item.genres!.join(' • '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + const SizedBox(height: 16), + if (_watchlistSource != null || _requestSource != null) + FocusableActionBar( + key: _actionBarKey, + onNavigateDown: _focusSectionBelowActions, + actions: [ + if (_watchlistSource != null) + FocusableAction( + icon: onWatchlist ?? false + ? Symbols.bookmark_added_rounded + : Symbols.bookmark_add_rounded, + tooltip: onWatchlist ?? false + ? t.explore.removeFromWatchlist + : t.explore.addToWatchlist, + onPressed: onWatchlist == null + ? () {} + : () => unawaited(_toggleWatchlist()), + ), + if (_requestSource case final SeerrCatalogSource seerr) + FocusableAction( + icon: Symbols.download_rounded, + tooltip: t.seerr.request, + onPressed: () => unawaited( + showSeerrRequestSheet( + context, + source: seerr, + kind: item.kind, + tmdbId: item.ids.tmdb!, + title: item.title, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), + ], + ), + if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips], + const SizedBox(height: 24), + if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 24), + _buildLibrarySection(theme), + if (_cast case final List cast when cast.isNotEmpty) ...[ + const SizedBox(height: 28), + _buildCastSection(theme, cast), + ], + if (_related case final List related when related.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildRelatedSection(related), ], - ), - if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips], - const SizedBox(height: 24), - if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge), - const SizedBox(height: 24), - _buildLibrarySection(theme), - if (_cast case final List cast when cast.isNotEmpty) ...[ - const SizedBox(height: 28), - _buildCastSection(theme, cast), ], - if (_related case final List related when related.isNotEmpty) ...[ - const SizedBox(height: 20), - _buildRelatedSection(related), - ], - ], + ), ), - ), - ], + ], + ), ), - ), - Positioned( - top: 0, - left: 0, - child: DesktopAppBarHelper.buildAdjustedLeading( - const AppBarBackButton(style: BackButtonStyle.circular), - context: context, - )!, - ), - ], + Positioned( + top: 0, + left: 0, + child: DesktopAppBarHelper.buildAdjustedLeading( + const AppBarBackButton(style: BackButtonStyle.circular), + context: context, + )!, + ), + ], + ), ), ), ); diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 0bd6874d..549479e7 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -26,6 +26,7 @@ import '../widgets/catalog_source_logo.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hub_section.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'; @@ -49,6 +50,7 @@ class ExploreScreenState extends State final Map> _hubKeysById = {}; List> _orderedHubKeys = const []; final _actionBarKey = GlobalKey(); + final _sourceMenuKey = GlobalKey>(); final _tvBrowseRailKey = GlobalKey(); final TvSpotlightController _spotlight = TvSpotlightController(); @@ -87,7 +89,11 @@ class ExploreScreenState extends State @override void focusActiveTabIfReady() { if (PlatformDetector.isTV()) { - _tvBrowseRailKey.currentState?.requestFocus(); + if (_explore.rowHubs.isNotEmpty) { + _tvBrowseRailKey.currentState?.requestFocus(); + } else { + _actionBarKey.currentState?.requestFocusOnFirst(); + } return; } _orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory(); @@ -133,26 +139,28 @@ class ExploreScreenState extends State CatalogRowId.upcomingMovies || CatalogRowId.upcomingShows => Symbols.event_upcoming_rounded, }; - /// 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); - } + 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, + }) { return AppMenuButton( + key: _sourceMenuKey, tooltip: t.explore.selectSource, + anchorAlignment: anchorAlignment, onSelected: (id) => unawaited(sources.setActiveSource(id)), - entriesBuilder: (context) => [ - for (final source in sources.connectedSources) - AppMenuItem( - value: source.id, - leading: CatalogSourceLogo(source.id), - label: source.displayName, - selected: source.id == active.id, - ), - ], + entriesBuilder: (context) => _sourceMenuEntries(sources, active), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( @@ -160,7 +168,7 @@ class ExploreScreenState extends State children: [ CatalogSourceLogo(active.id, size: 22), const SizedBox(width: 8), - Text(active.displayName, style: Theme.of(context).textTheme.titleLarge), + 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), ], @@ -169,6 +177,17 @@ class ExploreScreenState extends State ); } + /// 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(); @@ -176,6 +195,15 @@ class ExploreScreenState extends State 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 @@ -248,11 +276,6 @@ class ExploreScreenState extends State icon: Symbols.explore_rounded, ), ); - } else if (PlatformDetector.isTV()) { - return SettingsBuilder( - prefs: const [SettingsService.hideSpoilers, SettingsService.libraryDensity, SettingsService.episodePosterMode], - builder: (context) => _buildTvContent(rowHubs), - ); } else { content = CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -287,30 +310,131 @@ class ExploreScreenState extends State return null; } - Widget _buildTvContent(List rowHubs) { + 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, + ), + ), + 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: 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); - }, - onNavigateToSidebar: _navigateToSidebar, - onBack: _navigateToSidebar, - tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, - ), + 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!), + ), + ), + ], ), ); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 370e9de6..96846830 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -368,11 +368,7 @@ class _MediaDetailScreenState extends State late final FocusNode _overviewFocusNode; final _overviewSectionKey = GlobalKey(); - // Locked focus pattern for cast - int _focusedCastIndex = 0; - final ValueNotifier _focusedCastIndexNotifier = ValueNotifier(0); - late final FocusNode _castFocusNode; - final ScrollController _castScrollController = ScrollController(); + final _castStripKey = GlobalKey(); final _castSectionKey = GlobalKey(); final _seasonsSectionKey = GlobalKey(); @@ -678,7 +674,6 @@ class _MediaDetailScreenState extends State _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); _ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip'); _overviewFocusNode = FocusNode(debugLabel: 'overview'); - _castFocusNode = FocusNode(debugLabel: 'cast_row'); _infoRowsFocusNode = FocusNode(debugLabel: 'info_rows'); _loadFullMetadata(); _initWatchlistState(); @@ -860,10 +855,7 @@ class _MediaDetailScreenState extends State _playButtonFocusNode.dispose(); _ratingChipFocusNode.dispose(); _overviewFocusNode.dispose(); - _castFocusNode.dispose(); - _focusedCastIndexNotifier.dispose(); _infoRowsFocusNode.dispose(); - _castScrollController.dispose(); _extrasSelectLongPress.dispose(); for (final node in _seasonTabFocusNodes) { node.dispose(); @@ -2024,7 +2016,7 @@ class _MediaDetailScreenState extends State void _focusSectionAboveExtras() { final metadata = _fullMetadata ?? _metadata; if (metadata.roles != null && metadata.roles!.isNotEmpty) { - _castFocusNode.requestFocus(); + _castStripKey.currentState?.requestFocus(); _scrollSectionIntoView(_castSectionKey); } else { _focusSectionAboveCast(); @@ -2182,7 +2174,7 @@ class _MediaDetailScreenState extends State } if (metadata.roles != null && metadata.roles!.isNotEmpty) { - _castFocusNode.requestFocus(); + _castStripKey.currentState?.requestFocus(); _scrollSectionIntoView(_castSectionKey); return; } @@ -2227,7 +2219,7 @@ class _MediaDetailScreenState extends State _firstEpisodeFocusNode.requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (metadata.roles != null && metadata.roles!.isNotEmpty) { - _castFocusNode.requestFocus(); + _castStripKey.currentState?.requestFocus(); _scrollSectionIntoView(_castSectionKey); } else if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); @@ -2499,81 +2491,27 @@ class _MediaDetailScreenState extends State _extrasSelectLongPress.reset(); } - /// Handle key events for the cast row (locked focus pattern) - KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - if (key.isBackKey) return KeyEventResult.ignored; - if (!event.isActionable) return KeyEventResult.ignored; - - final metadata = _fullMetadata ?? _metadata; - final roleCount = metadata.roles?.length ?? 0; - - // LEFT: previous cast member - if (key.isLeftKey) { - if (_focusedCastIndex > 0) { - _focusedCastIndex--; - _focusedCastIndexNotifier.value = _focusedCastIndex; - scrollListToIndex( - _castScrollController, - _focusedCastIndex, - itemExtent: CastMemberStrip.itemExtentForCardWidth(_getResponsiveCardWidth()), - leadingPadding: 0, - ); - } - return KeyEventResult.handled; + void _focusSectionDirectlyAboveCast() { + if (_episodes.isNotEmpty) { + final useLastEpisode = _episodes.length > 1; + if (useLastEpisode) _suppressNextLastEpisodeFocusLoad = true; + final target = useLastEpisode ? _lastEpisodeFocusNode : _firstEpisodeFocusNode; + target.requestFocus(); + } else { + _focusSectionAboveCast(); } + } - // RIGHT: next cast member - if (key.isRightKey) { - if (_focusedCastIndex < roleCount - 1) { - _focusedCastIndex++; - _focusedCastIndexNotifier.value = _focusedCastIndex; - scrollListToIndex( - _castScrollController, - _focusedCastIndex, - itemExtent: CastMemberStrip.itemExtentForCardWidth(_getResponsiveCardWidth()), - leadingPadding: 0, - ); - } - return KeyEventResult.handled; + /// Focus the first visible section below cast. + void _focusSectionBelowCast() { + if (_extras != null && _extras!.isNotEmpty) { + _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); + } else if (_relatedHubs.isNotEmpty) { + _relatedHubKeys.first.currentState?.requestFocusFromMemory(); + } else if (_hasInfoRows) { + _focusInfoRows(); } - - if (key.isUpKey) { - // If episodes are visible, focus the last episode (cast is right below episodes) - if (_episodes.isNotEmpty) { - final useLastEpisode = _episodes.length > 1; - if (useLastEpisode) _suppressNextLastEpisodeFocusLoad = true; - final target = useLastEpisode ? _lastEpisodeFocusNode : _firstEpisodeFocusNode; - target.requestFocus(); - } else { - _focusSectionAboveCast(); - } - return KeyEventResult.handled; - } - - // DOWN: extras → related hubs → info rows → consume - if (key.isDownKey) { - if (_extras != null && _extras!.isNotEmpty) { - _extrasFocusNode.requestFocus(); - _scrollSectionIntoView(_extrasSectionKey); - } else if (_relatedHubs.isNotEmpty) { - _relatedHubKeys.first.currentState?.requestFocusFromMemory(); - } else if (_hasInfoRows) { - _focusInfoRows(); - } - return KeyEventResult.handled; - } - - // SELECT: navigate to actor media - if (key.isSelectKey) { - final metadata = _fullMetadata ?? _metadata; - if (_focusedCastIndex < (metadata.roles?.length ?? 0)) { - _navigateToActorMedia(metadata.roles![_focusedCastIndex]); - } - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; } /// Handle vertical navigation between related hub sections @@ -4499,19 +4437,13 @@ class _MediaDetailScreenState extends State Widget _buildCastSectionContent(MediaItem metadata) { final roles = metadata.roles!; - return Focus( - focusNode: _castFocusNode, - onKeyEvent: _handleCastKeyEvent, - child: ListenableBuilder( - listenable: Listenable.merge([_castFocusNode, _focusedCastIndexNotifier]), - builder: (context, _) => CastMemberStrip( - members: [for (final actor in roles) (name: actor.tag, secondary: actor.role, imagePath: actor.thumbPath)], - imageClient: getServerBoundMediaClient(context), - controller: _castScrollController, - focusedIndex: _castFocusNode.hasFocus ? _focusedCastIndex : null, - onMemberTap: (index) => _navigateToActorMedia(roles[index]), - ), - ), + return CastMemberStrip( + key: _castStripKey, + members: [for (final actor in roles) (name: actor.tag, secondary: actor.role, imagePath: actor.thumbPath)], + imageClient: getServerBoundMediaClient(context), + onNavigateUp: _focusSectionDirectlyAboveCast, + onNavigateDown: _focusSectionBelowCast, + onMemberTap: (index) => _navigateToActorMedia(roles[index]), ); } diff --git a/lib/widgets/cast_member_strip.dart b/lib/widgets/cast_member_strip.dart index b046e68f..678063dc 100644 --- a/lib/widgets/cast_member_strip.dart +++ b/lib/widgets/cast_member_strip.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/card_focus_scope.dart'; +import '../focus/dpad_navigator.dart'; import '../media/media_server_client.dart'; import '../services/settings_service.dart'; import '../theme/mono_tokens.dart'; import '../utils/grid_size_calculator.dart'; import '../utils/media_image_helper.dart'; +import '../utils/scroll_utils.dart'; import 'focus_builders.dart'; import 'horizontal_scroll_with_arrows.dart'; import 'optimized_media_image.dart'; @@ -16,10 +18,12 @@ import 'optimized_media_image.dart'; /// [CastMemberStrip.imageClient]) or an absolute URL. typedef CastStripMember = ({String name, String? secondary, String? imagePath}); -/// Horizontal cast/character strip shared by the media detail screen (server -/// items, actor navigation, dpad locked-focus) and the catalog detail screen -/// (provider items, display-only). -class CastMemberStrip extends StatelessWidget { +/// Horizontal cast/character strip shared by detail screens. +/// +/// Owns the locked-focus row model — focus node, highlighted index, horizontal +/// scrolling, and D-pad handling — while its parent only defines which section +/// comes before and after it. +class CastMemberStrip extends StatefulWidget { static const double _innerPadding = 3; final List members; @@ -27,20 +31,19 @@ class CastMemberStrip extends StatelessWidget { /// Resolves server-relative image paths; null when [members] carry /// absolute URLs. final MediaServerClient? imageClient; - final ScrollController? controller; - - /// Index highlighted by the owner's locked-focus dpad model; null when no - /// member is focused (or the owner has no focus model). - final int? focusedIndex; final void Function(int index)? onMemberTap; + final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateDown; + final String debugLabel; const CastMemberStrip({ super.key, required this.members, this.imageClient, - this.controller, - this.focusedIndex, this.onMemberTap, + this.onNavigateUp, + this.onNavigateDown, + this.debugLabel = 'cast_row', }); /// Card width matching the poster grids' cell width for the user's @@ -53,90 +56,192 @@ class CastMemberStrip extends StatelessWidget { /// The strip's fixed height for a given card width: /// image + inner padding + text area + list padding + focus scale headroom. - static double heightForCardWidth(double cardWidth) => cardWidth + _innerPadding * 2 + 58 + 10; + static double heightForCardWidth(double cardWidth) => cardWidth + _innerPadding * 2 + 66 + 10; - /// One item's horizontal extent (card + inner padding + trailing gap) for - /// owners doing their own ensure-visible scroll math (media detail dpad). - static double itemExtentForCardWidth(double cardWidth) => cardWidth + _innerPadding * 2 + 4; + static double _itemExtentForCardWidth(double cardWidth) => cardWidth + _innerPadding * 2 + 4; + + @override + State createState() => CastMemberStripState(); +} + +class CastMemberStripState extends State { + late final FocusNode _focusNode; + final ScrollController _scrollController = ScrollController(); + int _focusedIndex = 0; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: widget.debugLabel)..addListener(_handleFocusChange); + } + + @override + void didUpdateWidget(CastMemberStrip oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.debugLabel != oldWidget.debugLabel) { + _focusNode.debugLabel = widget.debugLabel; + } + if (widget.members.isEmpty) { + _focusedIndex = 0; + } else if (_focusedIndex >= widget.members.length) { + _focusedIndex = widget.members.length - 1; + } + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChange) + ..dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (mounted) setState(() {}); + } + + void requestFocus() { + if (widget.members.isEmpty) return; + _focusNode.requestFocus(); + _scrollToFocusedMember(); + } + + void _scrollToFocusedMember() { + scrollListToIndex( + _scrollController, + _focusedIndex, + itemExtent: CastMemberStrip._itemExtentForCardWidth(CastMemberStrip.responsiveCardWidth(context)), + leadingPadding: 0, + ); + } + + void _moveFocus(int delta) { + if (widget.members.isEmpty) return; + final target = (_focusedIndex + delta).clamp(0, widget.members.length - 1).toInt(); + if (target == _focusedIndex) return; + setState(() => _focusedIndex = target); + _scrollToFocusedMember(); + } + + void _activateMember(int index) { + if (_focusedIndex != index) setState(() => _focusedIndex = index); + _focusNode.requestFocus(); + widget.onMemberTap?.call(index); + } + + KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + if (key.isBackKey || !event.isActionable) return KeyEventResult.ignored; + if (widget.members.isEmpty) return KeyEventResult.ignored; + + if (key.isLeftKey) { + _moveFocus(-1); + return KeyEventResult.handled; + } + if (key.isRightKey) { + _moveFocus(1); + return KeyEventResult.handled; + } + if (key.isUpKey && widget.onNavigateUp != null) { + widget.onNavigateUp!(); + return KeyEventResult.handled; + } + if (key.isDownKey && widget.onNavigateDown != null) { + widget.onNavigateDown!(); + return KeyEventResult.handled; + } + if (key.isSelectKey && widget.onMemberTap != null) { + widget.onMemberTap!(_focusedIndex); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final nameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600); final secondaryStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant); - final cardWidth = responsiveCardWidth(context); + final cardWidth = CastMemberStrip.responsiveCardWidth(context); final imageSize = cardWidth; - return SizedBox( - height: heightForCardWidth(cardWidth), - child: HorizontalScrollWithArrows( - controller: controller, - builder: (scrollController) => ListView.builder( - addAutomaticKeepAlives: false, - addSemanticIndexes: false, - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: const EdgeInsets.symmetric(vertical: 5), - itemCount: members.length, - itemBuilder: (context, index) { - final member = members[index]; + return Focus( + focusNode: _focusNode, + descendantsAreFocusable: false, + onKeyEvent: _handleKeyEvent, + child: SizedBox( + height: CastMemberStrip.heightForCardWidth(cardWidth), + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + addAutomaticKeepAlives: false, + addSemanticIndexes: false, + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 5), + itemCount: widget.members.length, + itemBuilder: (context, index) { + final member = widget.members[index]; - return Padding( - padding: const EdgeInsets.only(right: 4), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: index == focusedIndex, - borderRadius: tokens(context).radiusSm, - onTap: onMemberTap == null ? null : () => onMemberTap!(index), - delegateFocusBorder: true, - child: Padding( - padding: const EdgeInsets.all(_innerPadding), - child: SizedBox( - width: cardWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CardFocusBorder( - borderRadius: tokens(context).radiusSm, - child: ClipRRect( - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: OptimizedMediaImage( - client: imageClient, - imagePath: member.imagePath, - width: imageSize, - height: imageSize, - fit: BoxFit.cover, - imageType: ImageType.avatar, - fallbackIcon: Symbols.person_rounded, + return Padding( + padding: const EdgeInsets.only(right: 4), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: _focusNode.hasFocus && index == _focusedIndex, + borderRadius: tokens(context).radiusSm, + onTap: widget.onMemberTap == null ? null : () => _activateMember(index), + delegateFocusBorder: true, + child: Padding( + padding: const EdgeInsets.all(CastMemberStrip._innerPadding), + child: SizedBox( + width: cardWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CardFocusBorder( + borderRadius: tokens(context).radiusSm, + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: OptimizedMediaImage( + client: widget.imageClient, + imagePath: member.imagePath, + width: imageSize, + height: imageSize, + fit: BoxFit.cover, + imageType: ImageType.avatar, + fallbackIcon: Symbols.person_rounded, + ), ), ), - ), - const SizedBox(height: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(member.name, style: nameStyle, maxLines: 2, overflow: TextOverflow.ellipsis), - if (member.secondary != null) ...[ - const SizedBox(height: 2), - Text( - member.secondary!, - style: secondaryStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + const SizedBox(height: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(member.name, style: nameStyle, maxLines: 2, overflow: TextOverflow.ellipsis), + if (member.secondary != null) ...[ + const SizedBox(height: 2), + Text( + member.secondary!, + style: secondaryStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], - ], + ), ), - ), - ], + ], + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 5956fadf..88ab8326 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -28,6 +28,14 @@ import '../utils/scroll_utils.dart'; import 'horizontal_scroll_with_arrows.dart'; import '../i18n/strings.g.dart'; +enum HubCardSizing { + /// Larger cards optimized for top-level TV shelves. + shelf, + + /// Grid-equivalent cards for shelves embedded in dense detail content. + grid, +} + /// Shared hub section widget used in both discover and library screens /// Displays a hub title with icon and a horizontal scrollable list of items /// @@ -68,6 +76,9 @@ class HubSection extends StatefulWidget { /// Use when the parent already provides edge spacing (e.g. inside Padding(16)). final bool inset; + /// Controls whether cards follow top-level shelf or grid geometry. + final HubCardSizing cardSizing; + /// Vertical viewport alignment when this hub is focused. final double focusScrollAlignment; @@ -87,6 +98,7 @@ class HubSection extends StatefulWidget { this.onNavigateUp, this.onNavigateToSidebar, this.inset = false, + this.cardSizing = HubCardSizing.shelf, this.focusScrollAlignment = 0.3, }) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching; @@ -449,7 +461,7 @@ class HubSectionState extends State with MountedSetStateMixin, Skele final svc = SettingsService.instanceOrNull; if (svc == null) return const SizedBox.shrink(); final density = svc.read(SettingsService.libraryDensity); - final baseCardWidth = isTv + final baseCardWidth = isTv && widget.cardSizing == HubCardSizing.shelf ? _getTvCardWidth(constraints.maxWidth, density, leadingPadding) : GridSizeCalculator.getCellWidth(constraints.maxWidth, context, density); diff --git a/lib/widgets/seerr_request_sheet.dart b/lib/widgets/seerr_request_sheet.dart index be2ce6ad..8c6166f4 100644 --- a/lib/widgets/seerr_request_sheet.dart +++ b/lib/widgets/seerr_request_sheet.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; import '../media/media_kind.dart'; import '../models/seerr/seerr_details.dart'; @@ -71,6 +72,8 @@ class _SeerrRequestSheetState extends State { /// TV only: requestable seasons (specials and empty seasons dropped). List _seasons = const []; final Set _selectedSeasons = {}; + List _seasonFocusNodes = const []; + late final FocusNode _requestButtonFocusNode; bool _is4k = false; @@ -111,9 +114,32 @@ class _SeerrRequestSheetState extends State { @override void initState() { super.initState(); + _requestButtonFocusNode = FocusNode(debugLabel: 'seerr_request_submit'); unawaited(_load()); } + @override + void dispose() { + for (final node in _seasonFocusNodes) { + node.dispose(); + } + _requestButtonFocusNode.dispose(); + super.dispose(); + } + + void _replaceSeasonFocusNodes(List seasons) { + for (final node in _seasonFocusNodes) { + node.dispose(); + } + _seasonFocusNodes = [ + for (final season in seasons) + FocusNode( + debugLabel: 'seerr_season_${season.seasonNumber}', + onKeyEvent: (node, event) => _handleSeasonKey(season.seasonNumber, event), + ), + ]; + } + Future _load() async { setState(() { _loading = true; @@ -141,6 +167,7 @@ class _SeerrRequestSheetState extends State { ]; } if (!mounted) return; + _replaceSeasonFocusNodes(seasons); setState(() { _settings = settings; _mediaInfo = mediaInfo; @@ -301,6 +328,40 @@ class _SeerrRequestSheetState extends State { } } + bool get _hasVisibleAdvancedControls { + if (!_advancedAllowed || _serversForVariant.isEmpty) return false; + final detail = _serverDetail; + return _serversForVariant.length > 1 || + (detail?.profiles?.isNotEmpty ?? false) || + (detail?.rootFolders?.isNotEmpty ?? false) || + (detail?.languageProfiles?.isNotEmpty ?? false); + } + + void _focusRequestButton() { + _requestButtonFocusNode.requestFocus(); + final buttonContext = _requestButtonFocusNode.context; + if (buttonContext == null) return; + unawaited( + Scrollable.ensureVisible( + buttonContext, + alignment: 0.9, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ), + ); + } + + KeyEventResult _handleSeasonKey(int seasonNumber, KeyEvent event) { + if (!event.isActionable || !event.logicalKey.isDownKey) { + return KeyEventResult.ignored; + } + if (_requestableSeasons.lastOrNull != seasonNumber || _can4k || _hasVisibleAdvancedControls || !_canSubmit) { + return KeyEventResult.ignored; + } + _focusRequestButton(); + return KeyEventResult.handled; + } + // ---------- UI ---------- @override @@ -348,6 +409,7 @@ class _SeerrRequestSheetState extends State { ], const SizedBox(height: 16), FilledButton.icon( + focusNode: _requestButtonFocusNode, onPressed: _canSubmit ? _submit : null, icon: _submitting ? const LoadingIndicatorBox() : const AppIcon(Symbols.download_rounded, fill: 1), label: Text(t.seerr.request), @@ -404,16 +466,17 @@ class _SeerrRequestSheetState extends State { contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), - for (final season in _seasons) _buildSeasonTile(theme, season), + for (var index = 0; index < _seasons.length; index++) _buildSeasonTile(theme, _seasons[index], index), const SizedBox(height: 8), ]; } - Widget _buildSeasonTile(ThemeData theme, SeerrSeason season) { + Widget _buildSeasonTile(ThemeData theme, SeerrSeason season, int index) { final number = season.seasonNumber; final blockedLabel = _seasonBlockedLabel(number); final episodeCount = season.episodeCount; return CheckboxListTile( + focusNode: _seasonFocusNodes[index], value: blockedLabel != null || _selectedSeasons.contains(number), onChanged: blockedLabel != null || _submitting ? null diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart new file mode 100644 index 00000000..23ec7729 --- /dev/null +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/models/catalog/catalog_cast_member.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/providers/catalog_sources_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/catalog_item_detail_screen.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/catalog_library_matcher.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:plezy/widgets/media_card.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; + +class _FakeCatalogSource implements CatalogSource { + final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier(); + + @override + CatalogSourceId get id => CatalogSourceId.trakt; + + @override + String get displayName => 'Trakt'; + + @override + bool get supportsWatchlist => true; + + @override + Listenable get watchlistChanges => _watchlistChanges; + + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async => const [ + CatalogCastMember(name: 'First Actor', secondary: 'Lead'), + CatalogCastMember(name: 'Second Actor', secondary: 'Support'), + ]; + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async => const [ + CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Related Movie', + ids: CatalogItemIds(tmdb: 2), + ), + ]; + + @override + bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => false; + + @override + void dispose() => _watchlistChanges.dispose(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCatalogSourcesProvider extends CatalogSourcesProvider { + final CatalogSource source; + + _FakeCatalogSourcesProvider(this.source); + + @override + List get connectedSources => [source]; +} + +class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher { + _FakeCatalogLibraryMatcher(super.multiServer, this.matches); + + final List matches; + + @override + Future> match(CatalogItem item) async => matches; +} + +const _item = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Catalog Movie', + overview: 'Overview', + ids: CatalogItemIds(tmdb: 1), +); + +Future _pumpDetail( + WidgetTester tester, + _FakeCatalogSource source, { + List matches = const [], + bool pushedRoute = false, +}) async { + final sources = _FakeCatalogSourcesProvider(source); + final serverManager = MultiServerManager(); + final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final matcher = _FakeCatalogLibraryMatcher(multiServer, matches); + addTearDown(sources.dispose); + addTearDown(source.dispose); + addTearDown(serverManager.dispose); + addTearDown(multiServer.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + Provider.value(value: matcher), + ChangeNotifierProvider.value(value: sources), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: pushedRoute + ? Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const CatalogItemDetailScreen(item: _item))), + child: const Text('Open catalog'), + ), + ), + ) + : const CatalogItemDetailScreen(item: _item), + ), + ), + ), + ); + await tester.pumpAndSettle(); + if (pushedRoute) { + await tester.tap(find.text('Open catalog')); + await tester.pumpAndSettle(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + TvDetectionService.debugSetAppleTVOverride(true); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('D-pad traverses from actions through cast and back from recommendations', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await _pumpDetail(tester, _FakeCatalogSource()); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row'); + expect( + tester.widget(find.byKey(const Key('catalog_detail_scroll'))).controller!.offset, + greaterThan(0), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, startsWith('hub_catalog-related:')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + expect(tester.widget(find.byKey(const Key('catalog_detail_scroll'))).controller!.offset, 0); + }); + + testWidgets('D-pad includes every library match between actions and cast', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final matches = [ + testMediaItem(id: 'match_1', libraryTitle: 'Movies', serverName: 'Living Room'), + testMediaItem(id: 'match_2', libraryTitle: 'Favorites', serverName: 'Bedroom'), + ]; + await _pumpDetail(tester, _FakeCatalogSource(), matches: matches); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_library_match_0'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_library_match_1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_library_match_1'); + }); + + testWidgets('TV Back closes a hosted sheet without popping the catalog route', (tester) async { + await _pumpDetail(tester, _FakeCatalogSource(), pushedRoute: true); + + final sheetResult = OverlaySheetController.showAdaptive( + tester.element(find.byType(FocusableActionBar)), + builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Hosted request sheet'))), + ); + await tester.pumpAndSettle(); + expect(find.text('Hosted request sheet'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); + await tester.pumpAndSettle(); + expect(find.text('Hosted request sheet'), findsNothing); + + // Android TV also dispatches a route-level back for the same remote press, + // sometimes after the sheet's close animation has completed. + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(CatalogItemDetailScreen), findsOneWidget); + await expectLater(sheetResult, completion(isNull)); + }); + + testWidgets('recommendation posters use compact grid-equivalent TV sizing', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1920, 1080); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await _pumpDetail(tester, _FakeCatalogSource()); + + expect(tester.getSize(find.byType(MediaCard).first).width, lessThan(210)); + }); +} diff --git a/test/screens/explore_screen_test.dart b/test/screens/explore_screen_test.dart new file mode 100644 index 00000000..f50e63f2 --- /dev/null +++ b/test/screens/explore_screen_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/providers/catalog_sources_provider.dart'; +import 'package:plezy/providers/explore_provider.dart'; +import 'package:plezy/screens/explore_screen.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +class _FakeCatalogSource implements CatalogSource { + _FakeCatalogSource(this.id, this.displayName, this.itemId); + + @override + final CatalogSourceId id; + + @override + final String displayName; + + final int? itemId; + final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier(); + + @override + List get supportedRows => const [CatalogRowId.popularMovies]; + + @override + bool get supportsWatchlist => false; + + @override + Listenable get watchlistChanges => _watchlistChanges; + + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async { + return CatalogPage( + items: [ + if (itemId case final itemId?) + CatalogItem( + source: id, + kind: MediaKind.movie, + title: '$displayName Movie', + ids: CatalogItemIds(tmdb: itemId), + ), + ], + ); + } + + @override + void dispose() => _watchlistChanges.dispose(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCatalogSourcesProvider extends CatalogSourcesProvider { + _FakeCatalogSourcesProvider(this.sources); + + final List sources; + + @override + List get connectedSources => sources; +} + +Future<_FakeCatalogSourcesProvider> _pumpExplore( + WidgetTester tester, { + int? traktItemId = 1, + int? malItemId = 2, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final trakt = _FakeCatalogSource(CatalogSourceId.trakt, 'Trakt', traktItemId); + final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId); + final sources = _FakeCatalogSourcesProvider([trakt, mal]); + final explore = ExploreProvider(sources); + addTearDown(explore.dispose); + addTearDown(sources.dispose); + addTearDown(trakt.dispose); + addTearDown(mal.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: sources), + ChangeNotifierProvider.value(value: explore), + ], + child: MaterialApp(theme: monoTheme(dark: true), home: const ExploreScreen()), + ), + ), + ); + await tester.pumpAndSettle(); + return sources; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + TvDetectionService.debugSetAppleTVOverride(true); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('TV source switcher is reachable from the browse rail and changes source', (tester) async { + final sources = await _pumpExplore(tester); + tester.state(find.byType(ExploreScreen)).focusActiveTabIfReady(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + expect(find.byTooltip(t.explore.selectSource), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ExploreSourceSwitcher'); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + expect(find.text('MyAnimeList'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(sources.activeSource?.id, CatalogSourceId.mal); + expect(find.text('MyAnimeList'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + }); + + testWidgets('TV source switcher remains focused when the active source has no rows', (tester) async { + final sources = await _pumpExplore(tester, traktItemId: null); + + tester.state(find.byType(ExploreScreen)).focusActiveTabIfReady(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ExploreSourceSwitcher'); + expect(find.byTooltip(t.explore.selectSource), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(sources.activeSource?.id, CatalogSourceId.mal); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + }); +} diff --git a/test/widgets/cast_member_strip_test.dart b/test/widgets/cast_member_strip_test.dart new file mode 100644 index 00000000..6734218b --- /dev/null +++ b/test/widgets/cast_member_strip_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/cast_member_strip.dart'; + +import '../test_helpers/prefs.dart'; + +const List _members = [ + (name: 'First Actor', secondary: 'Lead', imagePath: null), + (name: 'Second Actor', secondary: 'Support', imagePath: null), +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + TvDetectionService.debugSetAppleTVOverride(true); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('owns horizontal focus and delegates vertical section navigation', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final key = GlobalKey(); + var selectedIndex = -1; + var navigatedUp = 0; + var navigatedDown = 0; + + await tester.pumpWidget( + MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: CastMemberStrip( + key: key, + members: _members, + onMemberTap: (index) => selectedIndex = index, + onNavigateUp: () => navigatedUp++, + onNavigateDown: () => navigatedDown++, + ), + ), + ), + ); + + key.currentState!.requestFocus(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'cast_row'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(selectedIndex, 1); + expect(navigatedUp, 1); + expect(navigatedDown, 1); + }); + + testWidgets('clamps its focus index when the member list changes', (tester) async { + final key = GlobalKey(); + var members = _members; + late StateSetter setHostState; + + await tester.pumpWidget( + MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setHostState = setState; + return CastMemberStrip(key: key, members: members); + }, + ), + ), + ), + ); + + key.currentState!.requestFocus(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + setHostState(() => members = const []); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/seerr_request_sheet_test.dart b/test/widgets/seerr_request_sheet_test.dart index 40b1b8b2..8beca3ef 100644 --- a/test/widgets/seerr_request_sheet_test.dart +++ b/test/widgets/seerr_request_sheet_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -163,6 +164,15 @@ void main() { await tester.pump(); expect(tester.widget(submitFinder).onPressed, isNotNull); + final season2 = tester.widget( + find.ancestor(of: find.text('Season 2'), matching: find.byType(CheckboxListTile)), + ); + season2.focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'seerr_request_submit'); + await tester.tap(submitFinder); await tester.pumpAndSettle();