fix(tv): refine browse rail layout
This commit is contained in:
@@ -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())),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user