fix(catalog): restore TV focus navigation
This commit is contained in:
@@ -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
@@ -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!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user