fix(catalog): restore TV focus navigation

This commit is contained in:
edde746
2026-07-12 21:42:27 +02:00
parent 780a1ea180
commit 098a472670
10 changed files with 1293 additions and 369 deletions
+308 -149
View File
@@ -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<CatalogItemDetailScreen> {
final _actionBarKey = GlobalKey<FocusableActionBarState>();
final _castSectionKey = GlobalKey();
final _castStripKey = GlobalKey<CastMemberStripState>();
final _relatedSectionKey = GlobalKey<HubSectionState>();
final ScrollController _scrollController = ScrollController();
List<FocusNode> _libraryMatchFocusNodes = const [];
CatalogSource? _watchlistSource;
SeerrCatalogSource? _requestSource;
bool _mutatingWatchlist = false;
@@ -92,6 +102,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
@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<CatalogItemDetailScreen> {
Future<void> _resolveMatches() async {
try {
final matches = await context.read<CatalogLibraryMatcher>().match(widget.item);
if (mounted) setState(() => _matches = matches);
_setMatches(await context.read<CatalogLibraryMatcher>().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<MediaItem> 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<CatalogSourcesProvider>().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source);
@@ -139,6 +167,119 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
}
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<void> _toggleWatchlist() async {
@@ -159,6 +300,21 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
}
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<CatalogItemDetailScreen> {
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<CatalogItemDetailScreen> {
/// characters with their role, so the section is titled accordingly.
Widget _buildCastSection(ThemeData theme, List<CatalogCastMember> cast) {
return Column(
key: _castSectionKey,
crossAxisAlignment: .start,
children: [
Text(
@@ -268,9 +413,13 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
),
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<CatalogItemDetailScreen> {
/// the Explore rows (tap opens another catalog detail screen).
Widget _buildRelatedSection(List<CatalogItem> 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<CatalogItemDetailScreen> {
),
icon: Symbols.recommend_rounded,
inset: true,
onNavigateUp: _focusSectionAboveRelated,
cardSizing: HubCardSizing.grid,
);
}
@@ -301,151 +453,158 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
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<CatalogCastMember> cast when cast.isNotEmpty) ...[
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
if (_related case final List<CatalogItem> 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<CatalogCastMember> cast when cast.isNotEmpty) ...[
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
if (_related case final List<CatalogItem> 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,
)!,
),
],
),
),
),
);
+166 -42
View File
@@ -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<ExploreScreen>
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();
@@ -87,7 +89,11 @@ class ExploreScreenState extends State<ExploreScreen>
@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<ExploreScreen>
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<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,
}) {
return AppMenuButton<CatalogSourceId>(
key: _sourceMenuKey,
tooltip: t.explore.selectSource,
anchorAlignment: anchorAlignment,
onSelected: (id) => unawaited(sources.setActiveSource(id)),
entriesBuilder: (context) => [
for (final source in sources.connectedSources)
AppMenuItem<CatalogSourceId>(
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<ExploreScreen>
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<ExploreScreen>
);
}
/// 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>();
@@ -176,6 +195,15 @@ class ExploreScreenState extends State<ExploreScreen>
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<ExploreScreen>
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<ExploreScreen>
return null;
}
Widget _buildTvContent(List<ExploreRowHub> 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<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: 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!),
),
),
],
),
);
}
+29 -97
View File
@@ -368,11 +368,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
late final FocusNode _overviewFocusNode;
final _overviewSectionKey = GlobalKey();
// Locked focus pattern for cast
int _focusedCastIndex = 0;
final ValueNotifier<int> _focusedCastIndexNotifier = ValueNotifier<int>(0);
late final FocusNode _castFocusNode;
final ScrollController _castScrollController = ScrollController();
final _castStripKey = GlobalKey<CastMemberStripState>();
final _castSectionKey = GlobalKey();
final _seasonsSectionKey = GlobalKey();
@@ -678,7 +674,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_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<MediaDetailScreen>
_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<MediaDetailScreen>
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<MediaDetailScreen>
}
if (metadata.roles != null && metadata.roles!.isNotEmpty) {
_castFocusNode.requestFocus();
_castStripKey.currentState?.requestFocus();
_scrollSectionIntoView(_castSectionKey);
return;
}
@@ -2227,7 +2219,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_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<MediaDetailScreen>
_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<MediaDetailScreen>
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]),
);
}
+183 -78
View File
@@ -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<CastStripMember> 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<CastMemberStrip> createState() => CastMemberStripState();
}
class CastMemberStripState extends State<CastMemberStrip> {
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,
),
],
],
],
),
),
),
],
],
),
),
),
),
),
);
},
);
},
),
),
),
);
+13 -1
View File
@@ -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<HubSection> 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);
+65 -2
View File
@@ -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<SeerrRequestSheet> {
/// TV only: requestable seasons (specials and empty seasons dropped).
List<SeerrSeason> _seasons = const [];
final Set<int> _selectedSeasons = {};
List<FocusNode> _seasonFocusNodes = const [];
late final FocusNode _requestButtonFocusNode;
bool _is4k = false;
@@ -111,9 +114,32 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
@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<SeerrSeason> 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<void> _load() async {
setState(() {
_loading = true;
@@ -141,6 +167,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
];
}
if (!mounted) return;
_replaceSeasonFocusNodes(seasons);
setState(() {
_settings = settings;
_mediaInfo = mediaInfo;
@@ -301,6 +328,40 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
}
}
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<SeerrRequestSheet> {
],
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<SeerrRequestSheet> {
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
@@ -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<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [
CatalogCastMember(name: 'First Actor', secondary: 'Lead'),
CatalogCastMember(name: 'Second Actor', secondary: 'Support'),
];
@override
Future<List<CatalogItem>> 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<CatalogSource> get connectedSources => [source];
}
class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher {
_FakeCatalogLibraryMatcher(super.multiServer, this.matches);
final List<MediaItem> matches;
@override
Future<List<MediaItem>> match(CatalogItem item) async => matches;
}
const _item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Catalog Movie',
overview: 'Overview',
ids: CatalogItemIds(tmdb: 1),
);
Future<void> _pumpDetail(
WidgetTester tester,
_FakeCatalogSource source, {
List<MediaItem> 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<CatalogLibraryMatcher>.value(value: matcher),
ChangeNotifierProvider<CatalogSourcesProvider>.value(value: sources),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: pushedRoute
? Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(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<SingleChildScrollView>(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<SingleChildScrollView>(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<void>(
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));
});
}
+168
View File
@@ -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<CatalogRowId> get supportedRows => const [CatalogRowId.popularMovies];
@override
bool get supportsWatchlist => false;
@override
Listenable get watchlistChanges => _watchlistChanges;
@override
Future<CatalogPage> 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<CatalogSource> sources;
@override
List<CatalogSource> 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<CatalogSourcesProvider>.value(value: sources),
ChangeNotifierProvider<ExploreProvider>.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<ExploreScreenState>(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<ExploreScreenState>(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');
});
}
+99
View File
@@ -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<CastStripMember> _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<CastMemberStripState>();
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<CastMemberStripState>();
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);
});
}
@@ -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<FilledButton>(submitFinder).onPressed, isNotNull);
final season2 = tester.widget<CheckboxListTile>(
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();