feat(tv): improve hub rail experience

This commit is contained in:
edde746
2026-05-21 15:57:02 +02:00
parent 2b45557743
commit 9dc3a1a1b1
9 changed files with 775 additions and 414 deletions
+7
View File
@@ -26,6 +26,13 @@ class HubFocusMemory {
return _lastColumnHint.clamp(0, itemCount - 1); return _lastColumnHint.clamp(0, itemCount - 1);
} }
/// Get only this hub's remembered index, without falling back to the global column hint.
static int getForHubOnly(String hubKey, int itemCount, {int fallback = 0}) {
if (itemCount <= 0) return 0;
final remembered = _perHubMemory[hubKey];
return (remembered ?? fallback).clamp(0, itemCount - 1);
}
/// Clear all memory (e.g., when leaving a screen) /// Clear all memory (e.g., when leaving a screen)
static void clear() { static void clear() {
_perHubMemory.clear(); _perHubMemory.clear();
+2 -2
View File
@@ -1500,8 +1500,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
); );
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble(); final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
final minimumSpotlightBottom = railHeight + (16 * scale); final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble(); final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom ? minimumSpotlightBottom
: baseSpotlightBottom; : baseSpotlightBottom;
@@ -312,8 +312,8 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
); );
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble(); final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
final minimumSpotlightBottom = railHeight + (16 * scale); final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble(); final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom ? minimumSpotlightBottom
: baseSpotlightBottom; : baseSpotlightBottom;
+35 -23
View File
@@ -77,9 +77,10 @@ import '../widgets/tv_spotlight_background.dart';
part 'media_detail/action_buttons.dart'; part 'media_detail/action_buttons.dart';
const double _tvDetailTallPosterScale = 0.84; const double _tvDetailTallPosterScale = TvBrowseRailLayout.compactTallPosterScale;
const double _tvDetailEpisodeThumbnailScale = TvBrowseRailLayout.compactEpisodeThumbnailScale;
const double _tvDetailActionSize = 46; const double _tvDetailActionSize = 46;
const double _tvDetailActionRailGap = 8; const double _tvDetailActionRailGap = 4;
const String _tvDetailSeasonHubIdPrefix = 'detail_season_'; const String _tvDetailSeasonHubIdPrefix = 'detail_season_';
const String _tvDetailActorsHubId = 'detail_actors'; const String _tvDetailActorsHubId = 'detail_actors';
const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId';
@@ -667,6 +668,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_scrollController = ScrollController(); _scrollController = ScrollController();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_extrasFocusNode = FocusNode(debugLabel: 'extras_row'); _extrasFocusNode = FocusNode(debugLabel: 'extras_row');
_extrasFocusNode.addListener(_handleExtrasFocusChange);
_playButtonFocusNode = FocusNode(debugLabel: 'play_button'); _playButtonFocusNode = FocusNode(debugLabel: 'play_button');
_ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip'); _ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip');
_overviewFocusNode = FocusNode(debugLabel: 'overview'); _overviewFocusNode = FocusNode(debugLabel: 'overview');
@@ -791,6 +793,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_scrollController.dispose(); _scrollController.dispose();
_scrollOffset.dispose(); _scrollOffset.dispose();
_extrasScrollController.dispose(); _extrasScrollController.dispose();
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
_extrasFocusNode.dispose(); _extrasFocusNode.dispose();
_playButtonFocusNode.dispose(); _playButtonFocusNode.dispose();
_ratingChipFocusNode.dispose(); _ratingChipFocusNode.dispose();
@@ -2111,6 +2114,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
void _handleExtrasFocusChange() {
if (!_extrasFocusNode.hasFocus) _resetExtrasLongPressState();
}
void _resetExtrasLongPressState() {
_selectKeyTimer?.cancel();
_isSelectKeyDown = false;
_longPressTriggered = false;
}
/// Handle key events for the cast row (locked focus pattern) /// Handle key events for the cast row (locked focus pattern)
KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) { KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey; final key = event.logicalKey;
@@ -2873,6 +2886,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
onNavigateUp: _focusTvDetailActionRow, onNavigateUp: _focusTvDetailActionRow,
onBack: _popMediaDetailIfBackNotSuppressed, onBack: _popMediaDetailIfBackNotSuppressed,
tallPosterScale: _tvDetailTallPosterScale, tallPosterScale: _tvDetailTallPosterScale,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
initialHubId: _tvDetailInitialHubId(metadata), initialHubId: _tvDetailInitialHubId(metadata),
initialItemId: _tvDetailInitialItemId(metadata), initialItemId: _tvDetailInitialItemId(metadata),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
@@ -3077,12 +3091,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) {
final lineMetadata = _tvDetailFocusedEpisode ?? metadata;
final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index);
final parts = [ final parts = [
if (metadata.isMovie) t.discover.movie else if (metadata.isShow) t.discover.tvShow, if (lineMetadata.isEpisode && episodeLabel != null) episodeLabel,
if (metadata.rating != null) '${formatRating(metadata.rating!)}', if (lineMetadata.isMovie) t.discover.movie else if (lineMetadata.isShow) t.discover.tvShow,
if (metadata.contentRating != null) formatContentRating(metadata.contentRating!), if (lineMetadata.rating != null) '${formatRating(lineMetadata.rating!)}',
if (metadata.durationMs != null) formatDurationTextual(metadata.durationMs!), if (lineMetadata.contentRating != null) formatContentRating(lineMetadata.contentRating!),
if (metadata.year != null) metadata.year.toString(), if (lineMetadata.durationMs != null) formatDurationTextual(lineMetadata.durationMs!),
if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null)
formatFullDate(lineMetadata.originallyAvailableAt!)
else if (lineMetadata.year != null)
lineMetadata.year.toString(),
]; ];
return Text( return Text(
@@ -3093,12 +3113,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
); );
} }
String _tvDetailSummaryText(MediaItem metadata, String summary) {
final prefix = _tvDetailEpisodePrefix(metadata);
if (prefix == null) return summary;
return '$prefix: $summary';
}
String? _tvDetailDescription(MediaItem metadata, {required bool hideSpoilers}) { String? _tvDetailDescription(MediaItem metadata, {required bool hideSpoilers}) {
final focusedEpisode = _tvDetailFocusedEpisode; final focusedEpisode = _tvDetailFocusedEpisode;
if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
@@ -3117,17 +3131,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
if (showDescription != null) return showDescription; if (showDescription != null) return showDescription;
if (hideSpoilers && focusedEpisode.shouldHideSpoiler) { if (hideSpoilers && focusedEpisode.shouldHideSpoiler) return focusedEpisode.title;
return _tvDetailEpisodePrefix(focusedEpisode) ?? focusedEpisode.title;
}
return null; return null;
} }
String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers, bool showSpoilerFallback = true}) { String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers, bool showSpoilerFallback = true}) {
final shouldHideSpoiler = hideSpoilers && item.shouldHideSpoiler; final shouldHideSpoiler = hideSpoilers && item.shouldHideSpoiler;
final summary = shouldHideSpoiler ? null : item.summary; final summary = shouldHideSpoiler ? null : item.summary;
if (summary != null && summary.isNotEmpty) return _tvDetailSummaryText(item, summary); if (summary != null && summary.isNotEmpty) return summary;
if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return _tvDetailEpisodePrefix(item) ?? item.title; if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return item.title;
return null; return null;
} }
@@ -3144,11 +3156,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return null; return null;
} }
String? _tvDetailEpisodePrefix(MediaItem metadata) {
if (!metadata.isEpisode || metadata.parentIndex == null || metadata.index == null) return null;
return 'S${metadata.parentIndex}, E${metadata.index}';
}
double _estimateTvBrowseRailHeight(Size size, List<MediaHub> hubs) { double _estimateTvBrowseRailHeight(Size size, List<MediaHub> hubs) {
final svc = SettingsService.instanceOrNull!; final svc = SettingsService.instanceOrNull!;
return TvBrowseRailLayout.estimateHeight( return TvBrowseRailLayout.estimateHeight(
@@ -3157,6 +3164,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
density: svc.read(SettingsService.libraryDensity), density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterMode: svc.read(SettingsService.episodePosterMode),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
tallPosterScale: _tvDetailTallPosterScale, tallPosterScale: _tvDetailTallPosterScale,
); );
} }
@@ -3170,6 +3178,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
} }
double _tvDetailWidePosterScaleForHub(MediaHub hub) {
return _isTvDetailEpisodeHub(hub) ? _tvDetailEpisodeThumbnailScale : 1.0;
}
List<MediaHub> _tvDetailHubs(MediaItem metadata) { List<MediaHub> _tvDetailHubs(MediaItem metadata) {
final hubs = <MediaHub>[]; final hubs = <MediaHub>[];
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) {
+5
View File
@@ -173,6 +173,11 @@ String toBulletedString(List<String> parts) {
return parts.join(' · '); return parts.join(' · ');
} }
String? formatSeasonEpisodeLabel(int? season, int? episode) {
if (season == null || episode == null) return null;
return 'S$season E$episode';
}
String formatRating(double value) => String formatRating(double value) =>
value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1); value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1);
File diff suppressed because it is too large Load Diff
+9 -15
View File
@@ -166,8 +166,8 @@ class TvSpotlightBackground extends StatelessWidget {
if (summary != null && summary.isNotEmpty) ...[ if (summary != null && summary.isNotEmpty) ...[
SizedBox(height: _sectionGap(scale)), SizedBox(height: _sectionGap(scale)),
Text( Text(
_summaryText(media, summary), summary,
maxLines: compact ? 2 : 4, maxLines: compact ? 3 : 4,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.white.withValues(alpha: 0.78), color: Colors.white.withValues(alpha: 0.78),
@@ -178,7 +178,7 @@ class TvSpotlightBackground extends StatelessWidget {
] else if (shouldHideSpoiler && media.isEpisode) ...[ ] else if (shouldHideSpoiler && media.isEpisode) ...[
SizedBox(height: _sectionGap(scale)), SizedBox(height: _sectionGap(scale)),
Text( Text(
_episodePrefix(media) ?? media.title ?? '', media.title ?? '',
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
@@ -250,12 +250,17 @@ class TvSpotlightBackground extends StatelessWidget {
Widget _buildMetadataLine(BuildContext context, MediaItem media) { Widget _buildMetadataLine(BuildContext context, MediaItem media) {
final scale = _scale(context); final scale = _scale(context);
final episodeLabel = formatSeasonEpisodeLabel(media.parentIndex, media.index);
final parts = [ final parts = [
if (media.isEpisode && episodeLabel != null) episodeLabel,
if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow, if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow,
if (media.rating != null) '${formatRating(media.rating!)}', if (media.rating != null) '${formatRating(media.rating!)}',
if (media.contentRating != null) formatContentRating(media.contentRating!), if (media.contentRating != null) formatContentRating(media.contentRating!),
if (media.durationMs != null) formatDurationTextual(media.durationMs!), if (media.durationMs != null) formatDurationTextual(media.durationMs!),
if (media.year != null) media.year.toString(), if (media.isEpisode && media.originallyAvailableAt != null)
formatFullDate(media.originallyAvailableAt!)
else if (media.year != null)
media.year.toString(),
]; ];
return Text( return Text(
parts.join(''), parts.join(''),
@@ -310,15 +315,4 @@ class TvSpotlightBackground extends StatelessWidget {
), ),
); );
} }
String _summaryText(MediaItem media, String summary) {
final prefix = _episodePrefix(media);
if (prefix == null) return summary;
return '$prefix: $summary';
}
String? _episodePrefix(MediaItem media) {
if (!media.isEpisode || media.parentIndex == null || media.index == null) return null;
return 'S${media.parentIndex}, E${media.index}';
}
} }
+12
View File
@@ -114,6 +114,18 @@ void main() {
}); });
}); });
group('formatSeasonEpisodeLabel', () {
test('formats season and episode numbers', () {
expect(formatSeasonEpisodeLabel(1, 2), 'S1 E2');
expect(formatSeasonEpisodeLabel(0, 10), 'S0 E10');
});
test('requires both season and episode numbers', () {
expect(formatSeasonEpisodeLabel(null, 2), isNull);
expect(formatSeasonEpisodeLabel(1, null), isNull);
});
});
group('formatPlaybackRate', () { group('formatPlaybackRate', () {
test('1x formats as "1x" without normalAtOne', () { test('1x formats as "1x" without normalAtOne', () {
expect(formatPlaybackRate(1.0), '1x'); expect(formatPlaybackRate(1.0), '1x');
+281 -16
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/locked_hub_controller.dart'; import 'package:plezy/focus/locked_hub_controller.dart';
import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_hub.dart';
@@ -68,10 +69,21 @@ void main() {
episodePosterMode: EpisodePosterMode.episodeThumbnail, episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85, scale: 0.85,
); );
final compactForcedLayout = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
expect(defaultLayout.useWideLayout, isFalse); expect(defaultLayout.useWideLayout, isFalse);
expect(forcedLayout.useWideLayout, isTrue); expect(forcedLayout.useWideLayout, isTrue);
expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight)); expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight));
expect(compactForcedLayout.useWideLayout, isTrue);
expect(compactForcedLayout.cardWidth, lessThan(forcedLayout.cardWidth));
expect(compactForcedLayout.posterHeight, lessThan(forcedLayout.posterHeight));
}); });
test('estimated rail height is stable across mixed hub heights', () { test('estimated rail height is stable across mixed hub heights', () {
@@ -112,15 +124,26 @@ void main() {
scale: scale, scale: scale,
); );
final estimate = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [wideHub, posterHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
);
final posterSectionHeight = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: posterMetrics.height,
);
final expectedPosterEstimate =
TvBrowseRailLayout.railTopPaddingForScale(scale) +
TvBrowseRailLayout.viewportHeightFor(hubCount: 2, scale: scale, sectionHeight: posterSectionHeight) +
TvBrowseRailLayout.railBottomPaddingForScale(scale);
expect(posterMetrics.height, greaterThan(wideMetrics.height)); expect(posterMetrics.height, greaterThan(wideMetrics.height));
expect(maxHeight, posterMetrics.height); expect(maxHeight, posterMetrics.height);
expect(estimate, closeTo(expectedPosterEstimate, 0.001));
expect( expect(
TvBrowseRailLayout.estimateHeight( estimate,
size: size,
hubs: [wideHub, posterHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
),
TvBrowseRailLayout.estimateHeight( TvBrowseRailLayout.estimateHeight(
size: size, size: size,
hubs: [posterHub, wideHub], hubs: [posterHub, wideHub],
@@ -151,6 +174,29 @@ void main() {
expect(compactHeight, lessThan(defaultHeight)); expect(compactHeight, lessThan(defaultHeight));
}); });
test('multi-hub estimate reserves next hub peek height', () {
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
final showHub = MediaHub(id: 'shows', title: 'Shows', type: 'show', items: [movie], size: 1);
const size = Size(1280, 720);
final scale = TvBrowseRailLayout.scaleForSize(size);
final singleHubHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [movieHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
);
final multiHubHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [movieHub, showHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
);
expect(multiHubHeight - singleHubHeight, closeTo(TvBrowseRailLayout.nextHubPeekHeightForScale(scale), 0.001));
});
}); });
setUp(() async { setUp(() async {
@@ -319,6 +365,8 @@ void main() {
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 12); final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 12);
final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 12); final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 12);
final serverManager = MultiServerManager(); final serverManager = MultiServerManager();
final activeHubIds = <String>[];
var parentRebuilds = 0;
HubFocusMemory.setForHub(episodeHub.id, 5); HubFocusMemory.setForHub(episodeHub.id, 5);
await tester.pumpWidget( await tester.pumpWidget(
@@ -327,15 +375,23 @@ void main() {
child: MaterialApp( child: MaterialApp(
theme: monoTheme(dark: true), theme: monoTheme(dark: true),
home: Scaffold( home: Scaffold(
body: SizedBox( body: StatefulBuilder(
width: 700, builder: (context, setParentState) {
height: 720, return SizedBox(
child: TvBrowseRail( width: 700,
hubs: [movieHub, episodeHub], height: 720,
autofocus: true, child: TvBrowseRail(
iconForHub: (_, _) => Icons.tv_rounded, hubs: [movieHub, episodeHub],
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail, autofocus: true,
), iconForHub: (_, _) => Icons.tv_rounded,
onActiveHubChanged: (hub, _) {
activeHubIds.add(hub.id);
setParentState(() => parentRebuilds++);
},
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
);
},
), ),
), ),
), ),
@@ -345,12 +401,34 @@ void main() {
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus(); tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump(); await tester.pump();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final movieMetrics = TvBrowseRailLayout.metricsForHub(
hub: movieHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: movieMetrics.height,
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 100));
final midAnimationPosition = _verticalRailPosition(tester).pixels;
expect(midAnimationPosition, greaterThan(0));
expect(midAnimationPosition, lessThan(expectedVerticalOffset));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
final position = _activeRailPosition(tester); final position = _activeRailPosition(tester);
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); final verticalPosition = _verticalRailPosition(tester);
final metrics = TvBrowseRailLayout.metricsForHub( final metrics = TvBrowseRailLayout.metricsForHub(
hub: episodeHub, hub: episodeHub,
availableWidth: position.viewportDimension, availableWidth: position.viewportDimension,
@@ -362,7 +440,148 @@ void main() {
final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2); final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2);
final expectedOffset = (targetCenter - (position.viewportDimension / 2)).clamp(0.0, position.maxScrollExtent); final expectedOffset = (targetCenter - (position.viewportDimension / 2)).clamp(0.0, position.maxScrollExtent);
expect(activeHubIds.last, episodeHub.id);
expect(parentRebuilds, greaterThan(0));
expect(position.pixels, closeTo(expectedOffset, 0.1)); expect(position.pixels, closeTo(expectedOffset, 0.1));
expect(verticalPosition.pixels, closeTo(expectedVerticalOffset, 0.1));
});
testWidgets('uses per-hub item focus instead of global column hint', (tester) async {
List<MediaItem> movieItems() => List.generate(
8,
(index) =>
MediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'),
);
List<MediaItem> episodeItems() => List.generate(
8,
(index) => MediaItem(
id: 'episode_$index',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $index',
thumbPath: '/episode_$index',
),
);
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 8);
final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 8);
final focused = <String>[];
final serverManager = MultiServerManager();
Future<void> press(LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pump();
}
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setParentState) {
return SizedBox(
width: 700,
height: 720,
child: TvBrowseRail(
hubs: [movieHub, episodeHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onActiveHubChanged: (_, _) => setParentState(() {}),
onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'),
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
for (var i = 0; i < 5; i++) {
await press(LogicalKeyboardKey.arrowRight);
}
expect(focused.last, 'movies:movie_5');
await press(LogicalKeyboardKey.arrowDown);
expect(focused.last, 'episodes:episode_0');
await press(LogicalKeyboardKey.arrowUp);
expect(focused.last, 'movies:movie_5');
});
testWidgets('resets long-press state when context menu focus receives select key up', (tester) async {
final menuFocusNode = FocusNode(debugLabel: 'context_menu_probe');
addTearDown(menuFocusNode.dispose);
addTearDown(SelectKeyUpSuppressor.clearSuppression);
var activations = 0;
final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Stack(
children: [
SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
onActivateItem: (_, _) {
activations++;
return Future.value(true);
},
),
),
Focus(
focusNode: menuFocusNode,
onKeyEvent: (_, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled;
return KeyEventResult.ignored;
},
child: const SizedBox.shrink(),
),
],
),
),
),
),
);
await tester.pump();
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
railState.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 501));
menuFocusNode.requestFocus();
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
railState.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 100));
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 1);
}); });
testWidgets('does not autofocus unless requested', (tester) async { testWidgets('does not autofocus unless requested', (tester) async {
@@ -395,12 +614,58 @@ void main() {
await tester.pump(); await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
}); });
testWidgets('lays out when bottom-positioned in a stack', (tester) async {
final serverManager = MultiServerManager();
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 896,
height: 540,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
),
],
),
),
),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
});
} }
ScrollPosition _activeRailPosition(WidgetTester tester) { ScrollPosition _activeRailPosition(WidgetTester tester) {
return tester return tester
.stateList<ScrollableState>(find.byType(Scrollable)) .stateList<ScrollableState>(find.byType(Scrollable))
.map((state) => state.position) .map((state) => state.position)
.where((position) => axisDirectionToAxis(position.axisDirection) == Axis.horizontal)
.where((position) => position.maxScrollExtent > 0) .where((position) => position.maxScrollExtent > 0)
.reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b); .reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b);
} }
ScrollPosition _verticalRailPosition(WidgetTester tester) {
final scrollable = find.descendant(
of: find.byKey(const ValueKey('tv_browse_rail_vertical')),
matching: find.byType(Scrollable),
);
return tester
.stateList<ScrollableState>(scrollable)
.map((state) => state.position)
.singleWhere((position) => axisDirectionToAxis(position.axisDirection) == Axis.vertical);
}