feat(tv): improve hub rail experience
This commit is contained in:
@@ -26,6 +26,13 @@ class HubFocusMemory {
|
||||
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)
|
||||
static void clear() {
|
||||
_perHubMemory.clear();
|
||||
|
||||
@@ -1500,8 +1500,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||
);
|
||||
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
|
||||
final minimumSpotlightBottom = railHeight + (16 * scale);
|
||||
final baseSpotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble();
|
||||
final minimumSpotlightBottom = railHeight + (8 * scale);
|
||||
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
|
||||
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
|
||||
? minimumSpotlightBottom
|
||||
: baseSpotlightBottom;
|
||||
|
||||
@@ -312,8 +312,8 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||
);
|
||||
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
|
||||
final minimumSpotlightBottom = railHeight + (16 * scale);
|
||||
final baseSpotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble();
|
||||
final minimumSpotlightBottom = railHeight + (8 * scale);
|
||||
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
|
||||
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
|
||||
? minimumSpotlightBottom
|
||||
: baseSpotlightBottom;
|
||||
|
||||
@@ -77,9 +77,10 @@ import '../widgets/tv_spotlight_background.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 _tvDetailActionRailGap = 8;
|
||||
const double _tvDetailActionRailGap = 4;
|
||||
const String _tvDetailSeasonHubIdPrefix = 'detail_season_';
|
||||
const String _tvDetailActorsHubId = 'detail_actors';
|
||||
const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId';
|
||||
@@ -667,6 +668,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extrasFocusNode = FocusNode(debugLabel: 'extras_row');
|
||||
_extrasFocusNode.addListener(_handleExtrasFocusChange);
|
||||
_playButtonFocusNode = FocusNode(debugLabel: 'play_button');
|
||||
_ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip');
|
||||
_overviewFocusNode = FocusNode(debugLabel: 'overview');
|
||||
@@ -791,6 +793,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_scrollController.dispose();
|
||||
_scrollOffset.dispose();
|
||||
_extrasScrollController.dispose();
|
||||
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
|
||||
_extrasFocusNode.dispose();
|
||||
_playButtonFocusNode.dispose();
|
||||
_ratingChipFocusNode.dispose();
|
||||
@@ -2111,6 +2114,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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)
|
||||
KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
@@ -2873,6 +2886,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
onNavigateUp: _focusTvDetailActionRow,
|
||||
onBack: _popMediaDetailIfBackNotSuppressed,
|
||||
tallPosterScale: _tvDetailTallPosterScale,
|
||||
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
|
||||
initialHubId: _tvDetailInitialHubId(metadata),
|
||||
initialItemId: _tvDetailInitialItemId(metadata),
|
||||
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
|
||||
@@ -3077,12 +3091,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) {
|
||||
final lineMetadata = _tvDetailFocusedEpisode ?? metadata;
|
||||
final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index);
|
||||
final parts = [
|
||||
if (metadata.isMovie) t.discover.movie else if (metadata.isShow) t.discover.tvShow,
|
||||
if (metadata.rating != null) '★ ${formatRating(metadata.rating!)}',
|
||||
if (metadata.contentRating != null) formatContentRating(metadata.contentRating!),
|
||||
if (metadata.durationMs != null) formatDurationTextual(metadata.durationMs!),
|
||||
if (metadata.year != null) metadata.year.toString(),
|
||||
if (lineMetadata.isEpisode && episodeLabel != null) episodeLabel,
|
||||
if (lineMetadata.isMovie) t.discover.movie else if (lineMetadata.isShow) t.discover.tvShow,
|
||||
if (lineMetadata.rating != null) '★ ${formatRating(lineMetadata.rating!)}',
|
||||
if (lineMetadata.contentRating != null) formatContentRating(lineMetadata.contentRating!),
|
||||
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(
|
||||
@@ -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}) {
|
||||
final focusedEpisode = _tvDetailFocusedEpisode;
|
||||
if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
|
||||
@@ -3117,17 +3131,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
|
||||
if (showDescription != null) return showDescription;
|
||||
|
||||
if (hideSpoilers && focusedEpisode.shouldHideSpoiler) {
|
||||
return _tvDetailEpisodePrefix(focusedEpisode) ?? focusedEpisode.title;
|
||||
}
|
||||
if (hideSpoilers && focusedEpisode.shouldHideSpoiler) return focusedEpisode.title;
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers, bool showSpoilerFallback = true}) {
|
||||
final shouldHideSpoiler = hideSpoilers && item.shouldHideSpoiler;
|
||||
final summary = shouldHideSpoiler ? null : item.summary;
|
||||
if (summary != null && summary.isNotEmpty) return _tvDetailSummaryText(item, summary);
|
||||
if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return _tvDetailEpisodePrefix(item) ?? item.title;
|
||||
if (summary != null && summary.isNotEmpty) return summary;
|
||||
if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return item.title;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3144,11 +3156,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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) {
|
||||
final svc = SettingsService.instanceOrNull!;
|
||||
return TvBrowseRailLayout.estimateHeight(
|
||||
@@ -3157,6 +3164,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
density: svc.read(SettingsService.libraryDensity),
|
||||
episodePosterMode: svc.read(SettingsService.episodePosterMode),
|
||||
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
|
||||
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
|
||||
tallPosterScale: _tvDetailTallPosterScale,
|
||||
);
|
||||
}
|
||||
@@ -3170,6 +3178,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
|
||||
}
|
||||
|
||||
double _tvDetailWidePosterScaleForHub(MediaHub hub) {
|
||||
return _isTvDetailEpisodeHub(hub) ? _tvDetailEpisodeThumbnailScale : 1.0;
|
||||
}
|
||||
|
||||
List<MediaHub> _tvDetailHubs(MediaItem metadata) {
|
||||
final hubs = <MediaHub>[];
|
||||
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) {
|
||||
|
||||
@@ -173,6 +173,11 @@ String toBulletedString(List<String> parts) {
|
||||
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) =>
|
||||
value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1);
|
||||
|
||||
|
||||
+422
-356
File diff suppressed because it is too large
Load Diff
@@ -166,8 +166,8 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
if (summary != null && summary.isNotEmpty) ...[
|
||||
SizedBox(height: _sectionGap(scale)),
|
||||
Text(
|
||||
_summaryText(media, summary),
|
||||
maxLines: compact ? 2 : 4,
|
||||
summary,
|
||||
maxLines: compact ? 3 : 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
@@ -178,7 +178,7 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
] else if (shouldHideSpoiler && media.isEpisode) ...[
|
||||
SizedBox(height: _sectionGap(scale)),
|
||||
Text(
|
||||
_episodePrefix(media) ?? media.title ?? '',
|
||||
media.title ?? '',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
@@ -250,12 +250,17 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
|
||||
Widget _buildMetadataLine(BuildContext context, MediaItem media) {
|
||||
final scale = _scale(context);
|
||||
final episodeLabel = formatSeasonEpisodeLabel(media.parentIndex, media.index);
|
||||
final parts = [
|
||||
if (media.isEpisode && episodeLabel != null) episodeLabel,
|
||||
if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow,
|
||||
if (media.rating != null) '★ ${formatRating(media.rating!)}',
|
||||
if (media.contentRating != null) formatContentRating(media.contentRating!),
|
||||
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(
|
||||
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}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
test('1x formats as "1x" without normalAtOne', () {
|
||||
expect(formatPlaybackRate(1.0), '1x');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_hub.dart';
|
||||
@@ -68,10 +69,21 @@ void main() {
|
||||
episodePosterMode: EpisodePosterMode.episodeThumbnail,
|
||||
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(forcedLayout.useWideLayout, isTrue);
|
||||
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', () {
|
||||
@@ -112,15 +124,26 @@ void main() {
|
||||
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(maxHeight, posterMetrics.height);
|
||||
expect(estimate, closeTo(expectedPosterEstimate, 0.001));
|
||||
expect(
|
||||
TvBrowseRailLayout.estimateHeight(
|
||||
size: size,
|
||||
hubs: [wideHub, posterHub],
|
||||
density: LibraryDensity.max,
|
||||
episodePosterMode: EpisodePosterMode.episodeThumbnail,
|
||||
),
|
||||
estimate,
|
||||
TvBrowseRailLayout.estimateHeight(
|
||||
size: size,
|
||||
hubs: [posterHub, wideHub],
|
||||
@@ -151,6 +174,29 @@ void main() {
|
||||
|
||||
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 {
|
||||
@@ -319,6 +365,8 @@ void main() {
|
||||
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 serverManager = MultiServerManager();
|
||||
final activeHubIds = <String>[];
|
||||
var parentRebuilds = 0;
|
||||
HubFocusMemory.setForHub(episodeHub.id, 5);
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -327,15 +375,23 @@ void main() {
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 700,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
hubs: [movieHub, episodeHub],
|
||||
autofocus: true,
|
||||
iconForHub: (_, _) => Icons.tv_rounded,
|
||||
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
|
||||
),
|
||||
body: StatefulBuilder(
|
||||
builder: (context, setParentState) {
|
||||
return SizedBox(
|
||||
width: 700,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
hubs: [movieHub, episodeHub],
|
||||
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();
|
||||
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.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.pumpAndSettle();
|
||||
|
||||
final position = _activeRailPosition(tester);
|
||||
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
|
||||
final verticalPosition = _verticalRailPosition(tester);
|
||||
final metrics = TvBrowseRailLayout.metricsForHub(
|
||||
hub: episodeHub,
|
||||
availableWidth: position.viewportDimension,
|
||||
@@ -362,7 +440,148 @@ void main() {
|
||||
final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2);
|
||||
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(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 {
|
||||
@@ -395,12 +614,58 @@ void main() {
|
||||
await tester.pump();
|
||||
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) {
|
||||
return tester
|
||||
.stateList<ScrollableState>(find.byType(Scrollable))
|
||||
.map((state) => state.position)
|
||||
.where((position) => axisDirectionToAxis(position.axisDirection) == Axis.horizontal)
|
||||
.where((position) => position.maxScrollExtent > 0)
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user