fix(tv): refine browse rail layout

This commit is contained in:
edde746
2026-05-19 19:30:57 +02:00
parent 6be1b7c486
commit 7d60875e19
9 changed files with 856 additions and 414 deletions
+31 -10
View File
@@ -1193,12 +1193,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Text(
t.discover.title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
),
if (!PlatformDetector.isTV())
Text(
t.discover.title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
),
const Spacer(),
Consumer2<WatchTogetherProvider, CompanionRemoteProvider>(
builder: (context, watchTogether, companionRemote, _) {
@@ -1328,6 +1329,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
SettingsService.showServerNameOnHubs,
SettingsService.showHeroSection,
SettingsService.hideSpoilers,
SettingsService.libraryDensity,
SettingsService.episodePosterMode,
],
builder: (context) => _buildContent(context),
);
@@ -1483,11 +1486,28 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final size = MediaQuery.sizeOf(context);
final theme = Theme.of(context);
final spotlight = _effectiveSpotlightItem;
final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers);
final svc = SettingsService.instanceOrNull!;
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
final browseHubs = _tvBrowseHubs;
final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble();
final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble();
final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble();
final scale = TvLayoutConstants.scaleForSize(size);
final railHeight = browseHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
size: size,
hubs: browseHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
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 desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
return Material(
color: theme.scaffoldBackgroundColor,
@@ -1548,6 +1568,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
hub.id == 'continue_watching' ? _loadAllContinueWatchingItems() : Future.value(hub.items),
onNavigateUp: _focusTopActions,
onNavigateToSidebar: _navigateToSidebar,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
),
),
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())),
+32 -21
View File
@@ -1022,7 +1022,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
? allLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull
: null;
final showMobileTabsRow = selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context);
final useSideNavigation = PlatformDetector.shouldUseSideNavigation(context);
final showMobileTabsRow = selectedLibrary != null && !useSideNavigation;
final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt();
final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex];
final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended;
@@ -1133,36 +1134,46 @@ class _LibrariesScreenState extends State<LibrariesScreen>
),
);
} else if (selectedLibrary != null) {
Widget buildTabs() {
Widget buildTab(int index) {
return ClipRect(
child: _buildTabContent(
_visibleTabs[index],
library: selectedLibrary,
isActive: tabController.index == index,
tabIndex: index,
),
);
}
Widget buildTabs({bool activeOnly = false}) {
if (activeOnly) return buildTab(currentTabIndex);
final children = [for (int i = 0; i < _visibleTabs.length; i++) buildTab(i)];
return TabBarView(
key: ValueKey(_selectedLibraryGlobalKey),
controller: tabController,
// Disable swipe on desktop - trackpad scrolling triggers accidental tab switches
// Disable swipe on desktop/TV - trackpad and d-pad scroll actions can trigger accidental tab switches.
// See: https://github.com/flutter/flutter/issues/11132
physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null,
physics: useSideNavigation ? const NeverScrollableScrollPhysics() : null,
// Wrap each tab in ClipRect so horizontal overflow (e.g. hub rows
// with Clip.none) doesn't bleed into adjacent tabs during swipe transitions.
children: [
for (int i = 0; i < _visibleTabs.length; i++)
ClipRect(
child: _buildTabContent(
_visibleTabs[i],
library: selectedLibrary,
isActive: tabController.index == i,
tabIndex: i,
),
),
],
children: children,
);
}
if (useTvRecommendedBackdrop) {
body = Stack(
fit: StackFit.expand,
children: [
buildTabs(),
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())),
],
body = Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, event) => event.logicalKey.isDpadDirection ? KeyEventResult.handled : KeyEventResult.ignored,
child: Stack(
fit: StackFit.expand,
children: [
buildTabs(activeOnly: true),
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())),
],
),
);
} else {
body = NestedScrollView(
@@ -256,7 +256,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
_ensureHubKeys(items.length);
if (PlatformDetector.isTV()) {
return _buildTvContent(items);
return SettingsBuilder(
prefs: const [SettingsService.hideSpoilers, SettingsService.libraryDensity, SettingsService.episodePosterMode],
builder: (context) => _buildTvContent(items),
);
}
return CustomScrollView(
@@ -296,10 +299,27 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
final spotlight = _effectiveSpotlightItem;
final size = MediaQuery.sizeOf(context);
final theme = Theme.of(context);
final svc = SettingsService.instanceOrNull!;
final client = context.tryGetMediaClientForServer(spotlight?.serverId ?? widget.library.serverId);
final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble();
final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble();
final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble();
final scale = TvLayoutConstants.scaleForSize(size);
final railHeight = tvHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
size: size,
hubs: tvHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
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 desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
return Material(
color: theme.scaffoldBackgroundColor,
@@ -310,7 +330,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
TvSpotlightBackground(
item: spotlight,
client: client,
hideSpoilers: context.settingsRead(SettingsService.hideSpoilers),
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft,
@@ -333,6 +353,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
onNavigateUp: widget.onNavigateToChrome ?? widget.onBack,
onNavigateToSidebar: _navigateToSidebar,
onBack: widget.onBack,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
),
),
],
+1 -1
View File
@@ -1212,7 +1212,7 @@ class _MainScreenState extends State<MainScreen>
builder: (context, alwaysExpanded, _) {
final contentLeftPadding = alwaysExpanded
? SideNavigationRailState.expandedWidth
: SideNavigationRailState.collapsedWidth;
: SideNavigationRailState.collapsedWidthForContext(context);
return OverlaySheetHost(
child: PopScope(
+99 -8
View File
@@ -133,6 +133,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
bool _hasLoadedEpisodes = false;
double? _tvDetailPendingRailHeight;
double? _tvDetailStableRailHeight;
MediaItem? _tvDetailFocusedEpisode;
bool _tvDetailActionRowHasFocus = false;
// Inline season tabs
int _selectedSeasonIndex = 0;
@@ -2862,15 +2864,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
key: _tvDetailRailKey,
hubs: detailHubs,
iconForHub: _getTvDetailHubIcon,
onFocusedItemChanged: (_) {},
onFocusedHubItemChanged: _handleTvDetailFocusedRailItemChanged,
onRefresh: (itemId) => unawaited(_refreshItemInPlace(itemId)),
onActiveHubChanged: _handleTvDetailHubChanged,
onActivateItem: _handleTvDetailRailItemActivated,
onNavigateUp: () => _playButtonFocusNode.requestFocus(),
onNavigateUp: _focusTvDetailActionRow,
onBack: _popMediaDetailIfBackNotSuppressed,
tallPosterScale: _tvDetailTallPosterScale,
initialHubId: _tvDetailInitialHubId(metadata),
initialItemId: _tvDetailInitialItemId(metadata),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
),
),
],
@@ -2907,12 +2910,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
required double scale,
}) {
final theme = Theme.of(context);
final shouldHideSpoiler = hideSpoilers && metadata.shouldHideSpoiler;
final summary = shouldHideSpoiler ? null : metadata.summary;
final spoilerText = shouldHideSpoiler && metadata.isEpisode
? (_tvDetailEpisodePrefix(metadata) ?? metadata.title ?? '')
: null;
final description = summary != null && summary.isNotEmpty ? _tvDetailSummaryText(metadata, summary) : spoilerText;
final description = _tvDetailDescription(metadata, hideSpoilers: hideSpoilers);
return LayoutBuilder(
builder: (context, constraints) {
@@ -3099,6 +3097,51 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return '$prefix: $summary';
}
String? _tvDetailDescription(MediaItem metadata, {required bool hideSpoilers}) {
final focusedEpisode = _tvDetailFocusedEpisode;
if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
final episodeDescription = _tvDetailItemDescription(
focusedEpisode,
hideSpoilers: hideSpoilers,
showSpoilerFallback: false,
);
if (episodeDescription != null) return episodeDescription;
final season = _tvDetailSeasonForEpisode(focusedEpisode, metadata);
final seasonDescription = season == null ? null : _tvDetailItemDescription(season, hideSpoilers: hideSpoilers);
if (seasonDescription != null) return seasonDescription;
final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
if (showDescription != null) return showDescription;
if (hideSpoilers && focusedEpisode.shouldHideSpoiler) {
return _tvDetailEpisodePrefix(focusedEpisode) ?? 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;
return null;
}
MediaItem? _tvDetailSeasonForEpisode(MediaItem episode, MediaItem metadata) {
for (final season in _seasons) {
if (episode.parentId != null && season.id == episode.parentId) return season;
if (episode.parentIndex != null && season.index == episode.parentIndex) return season;
}
if (metadata.isSeason &&
((episode.parentId != null && metadata.id == episode.parentId) ||
(episode.parentIndex != null && metadata.index == episode.parentIndex))) {
return metadata;
}
return null;
}
String? _tvDetailEpisodePrefix(MediaItem metadata) {
if (!metadata.isEpisode || metadata.parentIndex == null || metadata.index == null) return null;
return 'S${metadata.parentIndex}, E${metadata.index}';
@@ -3111,10 +3154,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
hubs: hubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
tallPosterScale: _tvDetailTallPosterScale,
);
}
bool _isTvDetailEpisodeHub(MediaHub hub) {
return hub.id.startsWith(_tvDetailSeasonHubIdPrefix) || hub.id == 'detail_episodes';
}
EpisodePosterMode _tvDetailEpisodePosterModeForHub(MediaHub hub) {
if (_isTvDetailEpisodeHub(hub)) return EpisodePosterMode.episodeThumbnail;
return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode);
}
List<MediaHub> _tvDetailHubs(MediaItem metadata) {
final hubs = <MediaHub>[];
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) {
@@ -3203,7 +3256,45 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return true;
}
void _clearTvDetailFocusedEpisode() {
if (_tvDetailFocusedEpisode == null) return;
setStateIfMounted(() {
_tvDetailFocusedEpisode = null;
});
}
void _setTvDetailActionRowFocus(bool hasFocus) {
_tvDetailActionRowHasFocus = hasFocus;
if (hasFocus) _clearTvDetailFocusedEpisode();
}
void _focusTvDetailActionRow() {
_tvDetailActionRowHasFocus = true;
_clearTvDetailFocusedEpisode();
_playButtonFocusNode.requestFocus();
}
void _handleTvDetailFocusedRailItemChanged(MediaHub hub, MediaItem item) {
if (_tvDetailActionRowHasFocus) {
_clearTvDetailFocusedEpisode();
return;
}
if (!_isTvDetailEpisodeHub(hub) || !item.isEpisode) {
_clearTvDetailFocusedEpisode();
return;
}
if (_tvDetailFocusedEpisode?.id == item.id) return;
setStateIfMounted(() {
_tvDetailFocusedEpisode = item;
});
}
void _handleTvDetailHubChanged(MediaHub hub, int index) {
if (!_isTvDetailEpisodeHub(hub)) {
_clearTvDetailFocusedEpisode();
return;
}
if (hub.items.isEmpty) _clearTvDetailFocusedEpisode();
if (!hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return;
final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length));
if (seasonIndex == null || seasonIndex < 0 || seasonIndex >= _seasons.length) return;
+17 -2
View File
@@ -26,8 +26,8 @@ class HorizontalScrollWithArrows extends StatefulWidget {
}
class _HorizontalScrollWithArrowsState extends State<HorizontalScrollWithArrows> {
late final ScrollController _scrollController;
late final bool _ownsController;
late ScrollController _scrollController;
late bool _ownsController;
bool _isHovering = false;
bool _canScrollLeft = false;
bool _canScrollRight = false;
@@ -41,6 +41,21 @@ class _HorizontalScrollWithArrowsState extends State<HorizontalScrollWithArrows>
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState());
}
@override
void didUpdateWidget(covariant HorizontalScrollWithArrows oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller == widget.controller) return;
_scrollController.removeListener(_updateScrollState);
if (_ownsController) {
_scrollController.dispose();
}
_ownsController = widget.controller == null;
_scrollController = widget.controller ?? ScrollController();
_scrollController.addListener(_updateScrollState);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState());
}
@override
void dispose() {
_scrollController.removeListener(_updateScrollState);
+18 -3
View File
@@ -194,9 +194,22 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
bool _isTouchExpanded = false;
Timer? _collapseTimer;
static const double collapsedWidth = 80.0;
static const double tvCollapsedWidth = 64.0;
static const double expandedWidth = 220.0;
static const double _horizontalPadding = 12.0;
static const double _itemHorizontalPadding = 17.0;
static const double _defaultIconSize = 22.0;
static const Duration _collapseDelay = Duration(milliseconds: 150);
static double collapsedWidthForContext(BuildContext context) =>
PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth;
static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) {
if (!isCollapsed) return _horizontalPadding;
final centeredPadding = ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - _itemHorizontalPadding;
return centeredPadding.clamp(0.0, _horizontalPadding).toDouble();
}
static const _kHome = 'home';
static const _kLibraries = 'libraries';
static const _kSearch = 'search';
@@ -511,6 +524,8 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
}
final isCollapsed = !_shouldExpand;
final effectiveCollapsedWidth = collapsedWidthForContext(context);
final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed);
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
// Listen to fullscreen + groupLibrariesByServer setting so the rail
@@ -568,7 +583,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
child: AnimatedContainer(
duration: t.normal,
curve: Curves.easeOutCubic,
width: isCollapsed ? collapsedWidth : expandedWidth,
width: isCollapsed ? effectiveCollapsedWidth : expandedWidth,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(color: t.surface),
child: IgnorePointer(
@@ -583,7 +598,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
clipBehavior: Clip.hardEdge,
children: [
if (widget.isOfflineMode && widget.onReconnect != null) ...[
@@ -676,7 +691,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
if (_showFullscreenToggle)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12),
child: _buildFullscreenItem(isCollapsed: isCollapsed),
),
],
+421 -364
View File
@@ -55,13 +55,19 @@ class TvBrowseRailLayoutMetrics {
}
class TvBrowseRailLayout {
static const double compactTallPosterScale = 0.84;
static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size);
static double horizontalInsetForScale(double scale) => (24 * scale).clamp(18, 40).toDouble();
static double selectorWidthForScale(double scale) => (230 * scale).clamp(210, 310).toDouble();
static double railTopPaddingForScale(double scale) => 12 * scale;
static double selectorGapForScale(double scale) => 14 * scale;
static double railBottomPaddingForScale(double scale) => 8 * scale;
static double hubStripHeightForScale(double scale) => 44 * scale;
static double hubStripGapForScale(double scale) => 8 * scale;
static bool isPersonHub(MediaHub hub) => hub.type == 'person';
@@ -74,13 +80,12 @@ class TvBrowseRailLayout {
required double itemGap,
}) {
final f = LibraryDensity.factor(density);
final targetWidth = (useWideLayout ? 330 : 205) * scale * (1 + (f * 0.12));
final minCards = useWideLayout ? 3 : 5;
final maxCards = useWideLayout ? 7 : 12;
final cardCount = (availableWidth / targetWidth).floor().clamp(minCards, maxCards);
final fittedWidth = (availableWidth - horizontalPadding - (itemGap * cardCount)) / cardCount;
final minWidth = (useWideLayout ? 280 : 170) * scale;
final maxWidth = (useWideLayout ? 420 : 250) * scale;
final targetCards = useWideLayout ? 4.2 - (f * 1.4) : 7.0 - (f * 2.0);
final usableWidth = (availableWidth - horizontalPadding).clamp(1.0, double.infinity).toDouble();
final gapCount = targetCards > 1 ? targetCards - 1 : 0.0;
final fittedWidth = (usableWidth - (itemGap * gapCount)) / targetCards;
return fittedWidth.clamp(minWidth, maxWidth).toDouble();
}
@@ -129,37 +134,82 @@ class TvBrowseRailLayout {
);
}
static double estimateHeight({
required Size size,
static double maxActiveRailHeight({
required List<MediaHub> hubs,
required double availableWidth,
required int density,
required EpisodePosterMode episodePosterMode,
EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub,
required double scale,
double tallPosterScale = 1.0,
}) {
if (hubs.isEmpty) return 0;
final scale = scaleForSize(size);
final availableWidth =
size.width - horizontalInsetForScale(scale) - selectorWidthForScale(scale) - selectorGapForScale(scale);
if (availableWidth <= 0) return 0;
var activeRailHeight = 0.0;
var maxHeight = 0.0;
for (final hub in hubs) {
final metrics = metricsForHub(
hub: hub,
availableWidth: availableWidth,
density: density,
episodePosterMode: episodePosterMode,
episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode,
scale: scale,
tallPosterScale: tallPosterScale,
);
if (metrics.height > activeRailHeight) activeRailHeight = metrics.height;
if (metrics.height > maxHeight) maxHeight = metrics.height;
}
return maxHeight;
}
final visibleShelfCount = hubs.length < 5 ? hubs.length : 5;
final selectorHeight = (46 * scale * visibleShelfCount) + (4 * scale * (visibleShelfCount - 1).clamp(0, 4));
final rowHeight = activeRailHeight > selectorHeight ? activeRailHeight : selectorHeight;
return (12 * scale) + rowHeight + (24 * scale);
static double estimatedMaxScrollExtent({
required MediaHub hub,
required TvBrowseRailLayoutMetrics metrics,
required double viewportWidth,
required double scale,
}) {
final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap);
final moreContentWidth = hub.more ? (132 * scale) + metrics.itemGap : 0.0;
final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth;
return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble();
}
static double scrollOffsetForIndex({
required int index,
required TvBrowseRailLayoutMetrics metrics,
required double viewportWidth,
required double maxScrollExtent,
}) {
final itemExtent = metrics.cardWidth + metrics.itemGap;
final targetCenter = metrics.railEdgePadding + (index * itemExtent) + (itemExtent / 2);
return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble();
}
static double estimateHeight({
required Size size,
required List<MediaHub> hubs,
required int density,
required EpisodePosterMode episodePosterMode,
EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub,
double tallPosterScale = 1.0,
}) {
if (hubs.isEmpty) return 0;
final scale = scaleForSize(size);
final availableWidth = size.width - horizontalInsetForScale(scale);
if (availableWidth <= 0) return 0;
final activeRailHeight = maxActiveRailHeight(
hubs: hubs,
availableWidth: availableWidth,
density: density,
episodePosterMode: episodePosterMode,
episodePosterModeForHub: episodePosterModeForHub,
scale: scale,
tallPosterScale: tallPosterScale,
);
return railTopPaddingForScale(scale) +
hubStripHeightForScale(scale) +
hubStripGapForScale(scale) +
activeRailHeight +
railBottomPaddingForScale(scale);
}
}
@@ -167,6 +217,7 @@ class TvBrowseRail extends StatefulWidget {
final List<MediaHub> hubs;
final IconData Function(MediaHub hub, int index) iconForHub;
final ValueChanged<MediaItem>? onFocusedItemChanged;
final void Function(MediaHub hub, MediaItem item)? onFocusedHubItemChanged;
final void Function(String)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final bool Function(MediaHub hub)? isContinueWatchingHub;
@@ -180,12 +231,14 @@ class TvBrowseRail extends StatefulWidget {
final String? initialHubId;
final String? initialItemId;
final bool autofocus;
final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub;
const TvBrowseRail({
super.key,
required this.hubs,
required this.iconForHub,
this.onFocusedItemChanged,
this.onFocusedHubItemChanged,
this.onRefresh,
this.onRemoveFromContinueWatching,
this.isContinueWatchingHub,
@@ -199,6 +252,7 @@ class TvBrowseRail extends StatefulWidget {
this.initialHubId,
this.initialItemId,
this.autofocus = false,
this.episodePosterModeForHub,
});
@override
@@ -209,7 +263,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
static const _longPressDuration = Duration(milliseconds: 500);
final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail');
final ScrollController _scrollController = ScrollController();
final Map<String, ScrollController> _scrollControllers = {};
final ScrollController _hubStripController = ScrollController();
final Map<int, GlobalKey> _hubStripKeys = {};
final Map<String, GlobalKey<MediaCardState>> _mediaCardKeys = {};
int _hubIndex = 0;
@@ -221,6 +277,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
bool _longPressTriggered = false;
bool _hasUserChangedHub = false;
bool _hasUserChangedItem = false;
bool _railScrollCorrectionPending = false;
bool _hubStripCanScrollLeft = false;
bool _hubStripCanScrollRight = false;
MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)];
@@ -233,11 +292,14 @@ class TvBrowseRailState extends State<TvBrowseRail> {
void initState() {
super.initState();
_focusNode.addListener(_handleFocusChange);
_hubStripController.addListener(_updateHubStripScrollState);
_selectInitialHubIfPossible();
final selectedInitialItem = _selectInitialItemIfPossible();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || widget.hubs.isEmpty) return;
if (selectedInitialItem) _scrollToItem(animate: false);
_scrollHubStripToActive(animate: false);
_updateHubStripScrollState();
_notifyActiveHubChanged();
_notifyFocusedItem();
if (widget.autofocus) _focusNode.requestFocus();
@@ -277,6 +339,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (selectedInitialItem) _scrollToItem(animate: false);
_scrollHubStripToActive(animate: false);
_updateHubStripScrollState();
if (!oldWidget.autofocus && widget.autofocus) _focusNode.requestFocus();
if (activeHubChanged) _notifyActiveHubChanged();
_notifyFocusedItem();
@@ -288,7 +352,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_longPressTimer?.cancel();
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
_scrollController.dispose();
_hubStripController.removeListener(_updateHubStripScrollState);
for (final controller in _scrollControllers.values) {
controller.dispose();
}
_hubStripController.dispose();
super.dispose();
}
@@ -304,7 +372,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
void _notifyFocusedItem() {
final hub = _activeHub;
if (hub == null || hub.items.isEmpty || _itemIndex >= hub.items.length) return;
widget.onFocusedItemChanged?.call(hub.items[_itemIndex]);
final item = hub.items[_itemIndex];
widget.onFocusedItemChanged?.call(item);
widget.onFocusedHubItemChanged?.call(hub, item);
}
void _notifyActiveHubChanged() {
@@ -369,6 +439,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
if (backResult != KeyEventResult.ignored) return backResult;
}
if (key.isDpadDirection && event is KeyUpEvent) return KeyEventResult.handled;
if (!event.isActionable) return KeyEventResult.ignored;
final hub = _activeHub;
if (hub == null) return KeyEventResult.ignored;
@@ -433,10 +505,65 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_hubIndex = next;
_itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1);
_hasUserChangedHub = true;
_railScrollCorrectionPending = true;
});
_notifyFocusedItem();
_notifyActiveHubChanged();
_scrollToItem(animate: false);
_scrollToItemAfterLayout(animate: false, revealRail: true);
_scrollHubStripToActive();
}
void _scrollHubStripToActive({bool animate = true}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final key = _hubStripKeys[_hubIndex];
final context = key?.currentContext;
if (context == null) return;
unawaited(
Scrollable.ensureVisible(
context,
alignment: 0.35,
duration: animate ? const Duration(milliseconds: 180) : Duration.zero,
curve: Curves.easeOutCubic,
).then((_) {
if (mounted) _updateHubStripScrollState();
}),
);
});
}
void _scheduleHubStripScrollStateUpdate() {
WidgetsBinding.instance.addPostFrameCallback((_) => _updateHubStripScrollState());
}
void _updateHubStripScrollState() {
if (!mounted) return;
var canScrollLeft = false;
var canScrollRight = false;
if (_hubStripController.hasClients && _hubStripController.position.hasContentDimensions) {
const edgeTolerance = 0.5;
final position = _hubStripController.position;
canScrollLeft = position.pixels > position.minScrollExtent + edgeTolerance;
canScrollRight = position.pixels < position.maxScrollExtent - edgeTolerance;
}
if (canScrollLeft == _hubStripCanScrollLeft && canScrollRight == _hubStripCanScrollRight) return;
setState(() {
_hubStripCanScrollLeft = canScrollLeft;
_hubStripCanScrollRight = canScrollRight;
});
}
void _setHoveredItem(MediaHub hub, int index) {
if (_activeHub?.id != hub.id || index >= hub.items.length || _itemIndex == index) return;
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_rememberFocus(hub);
_notifyFocusedItem();
}
void _rememberFocus(MediaHub hub) {
@@ -444,8 +571,13 @@ class TvBrowseRailState extends State<TvBrowseRail> {
}
void _scrollToItem({bool animate = true}) {
final hub = _activeHub;
if (hub == null) return;
final controller = _scrollControllers[hub.id];
if (controller == null) return;
scrollListToIndex(
_scrollController,
controller,
_itemIndex,
itemExtent: _itemExtent,
leadingPadding: _railLeadingPadding,
@@ -453,6 +585,39 @@ class TvBrowseRailState extends State<TvBrowseRail> {
);
}
void _scrollToItemAfterLayout({bool animate = true, bool revealRail = false}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollToItem(animate: animate);
if (revealRail && _railScrollCorrectionPending) {
setState(() => _railScrollCorrectionPending = false);
}
});
}
ScrollController _scrollControllerForHub(
MediaHub hub,
TvBrowseRailLayoutMetrics metrics,
double viewportWidth,
double scale,
) {
return _scrollControllers.putIfAbsent(hub.id, () {
final maxScrollExtent = TvBrowseRailLayout.estimatedMaxScrollExtent(
hub: hub,
metrics: metrics,
viewportWidth: viewportWidth,
scale: scale,
);
final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex(
index: _itemIndex,
metrics: metrics,
viewportWidth: viewportWidth,
maxScrollExtent: maxScrollExtent,
);
return ScrollController(initialScrollOffset: initialScrollOffset);
});
}
GlobalKey<MediaCardState> _cardKeyFor(MediaHub hub, int itemIndex) {
return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey<MediaCardState>());
}
@@ -502,85 +667,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
double _horizontalInset(BuildContext context) => TvBrowseRailLayout.horizontalInsetForScale(_scale(context));
double _selectorWidth(BuildContext context) => TvBrowseRailLayout.selectorWidthForScale(_scale(context));
double _selectorGap(BuildContext context) => TvBrowseRailLayout.selectorGapForScale(_scale(context));
List<int> _visibleShelfIndices() {
const visibleCount = 5;
if (widget.hubs.length <= visibleCount) return List.generate(widget.hubs.length, (index) => index);
final start = (_hubIndex - 2).clamp(0, widget.hubs.length - visibleCount);
return List.generate(visibleCount, (index) => start + index);
}
_ShelfTitleParts _shelfTitleParts(String title) {
final titleWords = _titleWords(title);
if (titleWords.length < 3) return _ShelfTitleParts(title: title);
var bestPrefixLength = 0;
var bestSupport = 0;
for (var prefixLength = 2; prefixLength < titleWords.length; prefixLength++) {
if (_suffixStartsWithPunctuation(titleWords, prefixLength)) continue;
final support = _prefixSupport(titleWords, prefixLength);
if (support < 2) continue;
if (support > bestSupport || (support == bestSupport && prefixLength > bestPrefixLength)) {
bestSupport = support;
bestPrefixLength = prefixLength;
}
}
if (bestPrefixLength < 2) return _ShelfTitleParts(title: title);
bestPrefixLength = _preferConnectorBoundary(titleWords, bestPrefixLength, bestSupport);
return _splitTitleAtWord(title, bestPrefixLength);
}
List<String> _titleWords(String title) =>
title.trim().split(RegExp(r'\s+')).where((word) => word.isNotEmpty).toList();
int _prefixSupport(List<String> titleWords, int prefixLength) {
var support = 0;
for (final hub in widget.hubs) {
final otherWords = _titleWords(hub.title);
if (_commonPrefixLength(titleWords, otherWords) >= prefixLength) support++;
}
return support;
}
int _commonPrefixLength(List<String> a, List<String> b) {
final maxLength = a.length < b.length ? a.length : b.length;
var length = 0;
while (length < maxLength && a[length].toLowerCase() == b[length].toLowerCase()) {
length++;
}
return length;
}
int _preferConnectorBoundary(List<String> titleWords, int prefixLength, int support) {
for (var candidate = prefixLength; candidate >= 2; candidate--) {
if (_prefixSupport(titleWords, candidate) != support) continue;
if (_looksLikeConnector(titleWords[candidate - 1])) return candidate;
}
return prefixLength;
}
bool _looksLikeConnector(String word) {
final stripped = word.replaceAll(RegExp(r'[^\p{L}]', unicode: true), '');
return stripped.length <= 5 && stripped.isNotEmpty && stripped == stripped.toLowerCase();
}
bool _suffixStartsWithPunctuation(List<String> words, int prefixLength) {
if (prefixLength >= words.length) return true;
return RegExp(r'^[^\p{L}\p{N}]', unicode: true).hasMatch(words[prefixLength]);
}
_ShelfTitleParts _splitTitleAtWord(String title, int wordCount) {
final matches = RegExp(r'\S+').allMatches(title).toList();
if (wordCount <= 0 || wordCount >= matches.length) return _ShelfTitleParts(title: title);
final split = matches[wordCount - 1].end;
return _ShelfTitleParts(eyebrow: title.substring(0, split).trim(), title: title.substring(split).trim());
}
@override
Widget build(BuildContext context) {
final hub = _activeHub;
@@ -589,13 +675,17 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final theme = Theme.of(context);
final scale = _scale(context);
final horizontalInset = _horizontalInset(context);
final selectorGap = _selectorGap(context);
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: Container(
padding: EdgeInsets.fromLTRB(horizontalInset, 12 * scale, 0, 24 * scale),
padding: EdgeInsets.fromLTRB(
horizontalInset,
TvBrowseRailLayout.railTopPaddingForScale(scale),
0,
TvBrowseRailLayout.railBottomPaddingForScale(scale),
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
@@ -603,201 +693,146 @@ class TvBrowseRailState extends State<TvBrowseRail> {
colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)],
),
),
child: AnimatedOpacity(
opacity: hasFocus ? 1 : 0.6,
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHubStrip(context),
SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)),
_buildActiveRail(hub, hasFocus),
],
),
),
),
);
}
Widget _buildHubStrip(BuildContext context) {
final scale = _scale(context);
final height = TvBrowseRailLayout.hubStripHeightForScale(scale);
return SizedBox(
height: height,
child: ExcludeFocus(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(width: _selectorWidth(context), child: _buildShelfSelector(context)),
SizedBox(width: selectorGap),
Expanded(child: _buildActiveRail(hub, hasFocus)),
if (widget.hubs.length > 1) ...[
_buildHubStripAffordance(
scale: scale,
hasAbove: _hubIndex > 0,
hasBelow: _hubIndex < widget.hubs.length - 1,
),
SizedBox(width: 8 * scale),
],
Expanded(
child: NotificationListener<ScrollMetricsNotification>(
onNotification: (_) {
_scheduleHubStripScrollStateUpdate();
return false;
},
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) {
final fadeStop = bounds.width <= 0
? 0.08
: ((32 * scale) / bounds.width).clamp(0.02, 0.12).toDouble();
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
_hubStripCanScrollLeft ? Colors.transparent : Colors.white,
Colors.white,
Colors.white,
_hubStripCanScrollRight ? Colors.transparent : Colors.white,
],
stops: [0, fadeStop, 1 - fadeStop, 1],
).createShader(bounds);
},
child: ListView.separated(
controller: _hubStripController,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
clipBehavior: Clip.hardEdge,
padding: EdgeInsets.only(right: 36 * scale),
itemCount: widget.hubs.length,
separatorBuilder: (context, index) => SizedBox(width: 8 * scale),
itemBuilder: _buildHubStripChip,
),
),
),
),
],
),
),
);
}
Widget _buildShelfSelector(BuildContext context) {
Widget _buildHubStripChip(BuildContext context, int index) {
final scale = _scale(context);
final visibleIndices = _visibleShelfIndices();
final isScrollable = widget.hubs.length > visibleIndices.length;
final hasAbove = isScrollable && visibleIndices.first > 0;
final hasBelow = isScrollable && visibleIndices.last < widget.hubs.length - 1;
final rowHeight = 46 * scale;
final rowGap = 4 * scale;
final viewportHeight = isScrollable
? (rowHeight * 5) + (rowGap * 4)
: (rowHeight * visibleIndices.length) + (rowGap * (visibleIndices.length - 1).clamp(0, 4));
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: viewportHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
_buildFadedShelfRows(
visibleIndices: visibleIndices,
hasAbove: hasAbove,
hasBelow: hasBelow,
rowGap: rowGap,
rowHeight: rowHeight,
scale: scale,
viewportHeight: viewportHeight,
),
if (hasAbove) _buildSelectorChevron(Symbols.keyboard_arrow_up_rounded, scale, top: 0),
if (hasBelow) _buildSelectorChevron(Symbols.keyboard_arrow_down_rounded, scale, bottom: 0),
],
),
),
],
);
}
Widget _buildFadedShelfRows({
required List<int> visibleIndices,
required bool hasAbove,
required bool hasBelow,
required double rowGap,
required double rowHeight,
required double scale,
required double viewportHeight,
}) {
final rows = Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var visibleIndex = 0; visibleIndex < visibleIndices.length; visibleIndex++)
Padding(
padding: EdgeInsets.only(bottom: visibleIndex == visibleIndices.length - 1 ? 0 : rowGap),
child: _buildShelfRow(context, visibleIndices[visibleIndex], scale, rowHeight),
),
],
);
if (!hasAbove && !hasBelow) return rows;
final fadeStop = ((68 * scale) / viewportHeight).clamp(0.0, 0.45).toDouble();
final colors = <Color>[];
final stops = <double>[];
if (hasAbove) {
colors.addAll([Colors.transparent, Colors.white]);
stops.addAll([0, fadeStop]);
} else {
colors.add(Colors.white);
stops.add(0);
}
if (hasBelow) {
colors.addAll([Colors.white, Colors.transparent]);
stops.addAll([1 - fadeStop, 1]);
} else {
colors.add(Colors.white);
stops.add(1);
}
return ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: colors,
stops: stops,
).createShader(bounds),
child: rows,
);
}
Widget _buildShelfRow(BuildContext context, int index, double scale, double rowHeight) {
final colorScheme = Theme.of(context).colorScheme;
final isActive = index == _hubIndex;
final hub = widget.hubs[index];
final primaryColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.62);
return AnimatedContainer(
key: _hubStripKeys.putIfAbsent(index, () => GlobalKey()),
duration: const Duration(milliseconds: 160),
height: rowHeight,
padding: EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 6 * scale),
curve: Curves.easeOutCubic,
padding: EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 7 * scale),
decoration: BoxDecoration(
color: isActive ? Colors.white.withValues(alpha: 0.16) : Colors.transparent,
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(
widget.iconForHub(widget.hubs[index], index),
widget.iconForHub(hub, index),
fill: 1,
size: 22 * scale,
color: isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.54),
size: 21 * scale,
color: isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.5),
),
SizedBox(width: 8 * scale),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 260 * scale),
child: Text(
hub.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: primaryColor,
fontSize: 16 * scale,
height: 1,
fontWeight: isActive ? FontWeight.w800 : FontWeight.w600,
),
),
),
SizedBox(width: 12 * scale),
Expanded(child: _buildShelfTitle(context, widget.hubs[index], isActive, scale)),
],
),
);
}
Widget _buildSelectorChevron(IconData icon, double scale, {double? top, double? bottom}) {
return Positioned(
left: 0,
right: 0,
top: top,
bottom: bottom,
child: IgnorePointer(
child: Center(
child: AppIcon(icon, fill: 1, size: 18 * scale, color: Colors.white.withValues(alpha: 0.45)),
),
),
);
}
Widget _buildShelfTitle(BuildContext context, MediaHub hub, bool isActive, double scale) {
final parts = hub.id.startsWith('detail_season_')
? _ShelfTitleParts(title: hub.title)
: _shelfTitleParts(hub.title);
final colorScheme = Theme.of(context).colorScheme;
final primaryColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.62);
final secondaryColor = isActive
? Colors.white.withValues(alpha: 0.62)
: colorScheme.onSurface.withValues(alpha: 0.42);
if (parts.eyebrow == null) {
return Text(
parts.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: primaryColor,
fontSize: 16 * scale,
height: 1.05,
fontWeight: isActive ? FontWeight.w800 : FontWeight.w600,
),
);
}
Widget _buildHubStripAffordance({required double scale, required bool hasAbove, required bool hasBelow}) {
final enabledColor = Colors.white.withValues(alpha: 0.62);
final disabledColor = Colors.white.withValues(alpha: 0.18);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
parts.eyebrow!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: secondaryColor,
fontSize: 10.5 * scale,
height: 0.95,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
AppIcon(
Symbols.keyboard_arrow_up_rounded,
fill: 1,
size: 12 * scale,
color: hasAbove ? enabledColor : disabledColor,
),
SizedBox(height: scale),
Text(
parts.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: primaryColor,
fontSize: 16 * scale,
height: 1,
fontWeight: isActive ? FontWeight.w800 : FontWeight.w600,
),
AppIcon(
Symbols.keyboard_arrow_down_rounded,
fill: 1,
size: 12 * scale,
color: hasBelow ? enabledColor : disabledColor,
),
],
);
@@ -810,7 +845,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
builder: (context, constraints) {
final svc = SettingsService.instanceOrNull!;
final density = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final EpisodePosterMode episodePosterMode =
widget.episodePosterModeForHub?.call(hub) ?? svc.read(SettingsService.episodePosterMode);
final scale = _scale(context);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
@@ -820,96 +856,124 @@ class TvBrowseRailState extends State<TvBrowseRail> {
scale: scale,
tallPosterScale: widget.tallPosterScale,
);
final scrollController = _scrollControllerForHub(hub, metrics, constraints.maxWidth, scale);
final maxActiveRailHeight = TvBrowseRailLayout.maxActiveRailHeight(
hubs: widget.hubs,
availableWidth: constraints.maxWidth,
density: density,
episodePosterMode: svc.read(SettingsService.episodePosterMode),
episodePosterModeForHub: widget.episodePosterModeForHub,
scale: scale,
tallPosterScale: widget.tallPosterScale,
);
_railLeadingPadding = metrics.railEdgePadding;
_itemExtent = metrics.cardWidth + metrics.itemGap;
return SizedBox(
height: metrics.height,
child: ClipRect(
clipper: _RailClipper(
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap,
verticalOverflow: metrics.focusExtra,
),
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: EdgeInsets.symmetric(horizontal: metrics.railEdgePadding, vertical: 6 * scale),
itemCount: _totalItemCount(hub),
itemBuilder: (context, index) {
final isFocused = hasFocus && index == _itemIndex;
if (index == hub.items.length) {
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_navigateToHubDetail(hub);
},
child: SizedBox(
width: 132 * scale,
height: metrics.containerHeight - metrics.itemGap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(Symbols.arrow_forward_rounded, fill: 1, size: 42 * scale, color: Colors.white),
SizedBox(height: 6 * scale),
Text(
t.common.viewAll,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
return Opacity(
opacity: _railScrollCorrectionPending ? 0 : 1,
child: SizedBox(
height: maxActiveRailHeight,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
height: metrics.height,
child: ClipRect(
clipper: _RailClipper(
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap,
verticalOverflow: metrics.focusExtra,
),
child: HorizontalScrollWithArrows(
controller: scrollController,
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: EdgeInsets.symmetric(horizontal: metrics.railEdgePadding, vertical: 6 * scale),
itemCount: _totalItemCount(hub),
itemBuilder: (context, index) {
final isFocused = hasFocus && index == _itemIndex;
if (index == hub.items.length) {
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_navigateToHubDetail(hub);
},
child: SizedBox(
width: 132 * scale,
height: metrics.containerHeight - metrics.itemGap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(
Symbols.arrow_forward_rounded,
fill: 1,
size: 42 * scale,
color: Colors.white,
),
SizedBox(height: 6 * scale),
Text(
t.common.viewAll,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
),
],
),
),
],
),
),
),
);
}
final item = hub.items[index];
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_activateCurrentItem();
},
onLongPress: metrics.isPersonHub
? null
: () => _cardKeyFor(hub, index).currentState?.showContextMenu(),
child: metrics.isPersonHub
? _buildPersonCard(
context,
item,
cardWidth: metrics.cardWidth,
imageSize: metrics.posterHeight,
scale: scale,
)
: MediaCard(
key: _cardKeyFor(hub, index),
item: item,
width: metrics.cardWidth,
height: metrics.posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
mixedHubContext: metrics.isMixedHub,
),
);
}
final item = hub.items[index];
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: MouseRegion(
onEnter: (_) => _setHoveredItem(hub, index),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
setState(() {
_itemIndex = index;
_hasUserChangedItem = true;
});
_activateCurrentItem();
},
onLongPress: metrics.isPersonHub
? null
: () => _cardKeyFor(hub, index).currentState?.showContextMenu(),
child: metrics.isPersonHub
? _buildPersonCard(
context,
item,
cardWidth: metrics.cardWidth,
imageSize: metrics.posterHeight,
scale: scale,
)
: MediaCard(
key: _cardKeyFor(hub, index),
item: item,
width: metrics.cardWidth,
height: metrics.posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
mixedHubContext: metrics.isMixedHub,
episodePosterModeOverride: episodePosterMode,
),
),
),
);
},
),
);
},
),
),
),
),
),
@@ -996,10 +1060,3 @@ class _RailClipper extends CustomClipper<Rect> {
return oldClipper.rightOverflow != rightOverflow || oldClipper.verticalOverflow != verticalOverflow;
}
}
class _ShelfTitleParts {
final String? eyebrow;
final String title;
const _ShelfTitleParts({this.eyebrow, required this.title});
}
+211
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/locked_hub_controller.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
@@ -17,9 +19,144 @@ import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('TvBrowseRailLayout', () {
test('density changes card width', () {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Movies', type: 'movie', items: [item], size: 1);
final compact = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.min,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
);
final comfortable = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
);
expect(comfortable.cardWidth, greaterThan(compact.cardWidth));
expect(comfortable.posterWidth, greaterThan(compact.posterWidth));
});
test('detail episode hubs can force episode thumbnails', () {
final episode = MediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
grandparentThumbPath: '/show-poster',
);
final hub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1);
final defaultLayout = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
);
final forcedLayout = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
);
expect(defaultLayout.useWideLayout, isFalse);
expect(forcedLayout.useWideLayout, isTrue);
expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight));
});
test('estimated rail height is stable across mixed hub heights', () {
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final episode = MediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
);
final posterHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
final wideHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1);
const size = Size(1280, 720);
final scale = TvBrowseRailLayout.scaleForSize(size);
final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);
final posterMetrics = TvBrowseRailLayout.metricsForHub(
hub: posterHub,
availableWidth: availableWidth,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final wideMetrics = TvBrowseRailLayout.metricsForHub(
hub: wideHub,
availableWidth: availableWidth,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final maxHeight = TvBrowseRailLayout.maxActiveRailHeight(
hubs: [wideHub, posterHub],
availableWidth: availableWidth,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
expect(posterMetrics.height, greaterThan(wideMetrics.height));
expect(maxHeight, posterMetrics.height);
expect(
TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [wideHub, posterHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
),
TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [posterHub, wideHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
),
);
});
test('compact tall poster scale reduces browse rail height', () {
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
const size = Size(1280, 720);
final defaultHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
);
final compactHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
expect(compactHeight, lessThan(defaultHeight));
});
});
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
HubFocusMemory.clear();
await SettingsService.getInstance();
});
@@ -120,6 +257,72 @@ void main() {
expect(focusedItemIds.last, episode2.id);
});
testWidgets('scrolls remembered item after switching hubs', (tester) async {
List<MediaItem> movieItems() => List.generate(
12,
(index) =>
MediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'),
);
List<MediaItem> episodeItems() => List.generate(
12,
(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: 12);
final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 12);
final serverManager = MultiServerManager();
HubFocusMemory.setForHub(episodeHub.id, 5);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
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,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
final position = _activeRailPosition(tester);
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: episodeHub,
availableWidth: position.viewportDimension,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final itemExtent = metrics.cardWidth + metrics.itemGap;
final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2);
final expectedOffset = (targetCenter - (position.viewportDimension / 2)).clamp(0.0, position.maxScrollExtent);
expect(position.pixels, closeTo(expectedOffset, 0.1));
});
testWidgets('does not autofocus unless requested', (tester) async {
FocusManager.instance.primaryFocus?.unfocus();
@@ -151,3 +354,11 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
});
}
ScrollPosition _activeRailPosition(WidgetTester tester) {
return tester
.stateList<ScrollableState>(find.byType(Scrollable))
.map((state) => state.position)
.where((position) => position.maxScrollExtent > 0)
.reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b);
}