feat(tv): refine sidebar backdrop

This commit is contained in:
edde746
2026-05-21 17:04:23 +02:00
parent 6a74ff083f
commit 743a8668ec
8 changed files with 312 additions and 179 deletions
+27 -10
View File
@@ -41,6 +41,7 @@ import '../providers/user_profile_provider.dart';
import '../services/storage_service.dart';
import '../services/settings_service.dart';
import '../widgets/settings_builder.dart';
import '../widgets/side_navigation_rail.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
import '../mixins/refreshable.dart';
@@ -1508,20 +1509,30 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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();
final sidebarBleed = svc.read(SettingsService.alwaysKeepSidebarOpen)
? 0.0
: SideNavigationRailState.collapsedWidthForContext(context);
return Material(
color: theme.scaffoldBackgroundColor,
child: Stack(
clipBehavior: Clip.none,
children: [
TvSpotlightBackground(
item: spotlight,
client: _getMediaClientForItem(spotlight),
hideSpoilers: hideSpoilers,
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft,
compact: true,
showPrimaryAction: false,
Positioned(
top: 0,
bottom: 0,
left: -sidebarBleed,
right: 0,
child: TvSpotlightBackground(
item: spotlight,
client: _getMediaClientForItem(spotlight),
hideSpoilers: hideSpoilers,
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + sidebarBleed,
compact: true,
showPrimaryAction: false,
),
),
if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()),
if (_errorMessage != null)
@@ -1569,9 +1580,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
onNavigateUp: _focusTopActions,
onNavigateToSidebar: _navigateToSidebar,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
backgroundBleedLeft: sidebarBleed,
),
),
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())),
Positioned(
top: 0,
left: -sidebarBleed,
right: 0,
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
),
if (_switchingProfile) const ProfileSwitchingOverlay(),
],
),
+9 -7
View File
@@ -1135,14 +1135,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
} else if (selectedLibrary != null) {
Widget buildTab(int index) {
return ClipRect(
child: _buildTabContent(
_visibleTabs[index],
library: selectedLibrary,
isActive: tabController.index == index,
tabIndex: index,
),
final tabContent = _buildTabContent(
_visibleTabs[index],
library: selectedLibrary,
isActive: tabController.index == index,
tabIndex: index,
);
if (useTvRecommendedBackdrop) return tabContent;
return ClipRect(child: tabContent);
}
Widget buildTabs({bool activeOnly = false}) {
@@ -1169,6 +1170,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
onKeyEvent: (_, event) => event.logicalKey.isDpadDirection ? KeyEventResult.handled : KeyEventResult.ignored,
child: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
buildTabs(activeOnly: true),
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())),
@@ -16,6 +16,7 @@ import '../../../utils/provider_extensions.dart';
import '../../../utils/watch_state_notifier.dart';
import '../../../widgets/hub_section.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/side_navigation_rail.dart';
import '../../../widgets/tv_browse_rail.dart';
import '../../../widgets/tv_spotlight_background.dart';
import '../../main_screen.dart';
@@ -320,22 +321,32 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
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();
final sidebarBleed = svc.read(SettingsService.alwaysKeepSidebarOpen)
? 0.0
: SideNavigationRailState.collapsedWidthForContext(context);
return Material(
color: theme.scaffoldBackgroundColor,
child: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
TvSpotlightBackground(
item: spotlight,
client: client,
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft,
compact: true,
showPrimaryAction: false,
Positioned(
top: 0,
bottom: 0,
left: -sidebarBleed,
right: 0,
child: TvSpotlightBackground(
item: spotlight,
client: client,
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + sidebarBleed,
compact: true,
showPrimaryAction: false,
),
),
if (tvHubs.isNotEmpty)
Positioned(
@@ -354,6 +365,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
onNavigateToSidebar: _navigateToSidebar,
onBack: widget.onBack,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
backgroundBleedLeft: sidebarBleed,
),
),
],
+1
View File
@@ -744,6 +744,7 @@ class _MainScreenState extends State<MainScreen>
Expanded(
child: IndexedStack(
index: _currentIndex,
clipBehavior: Clip.none,
children: [
for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i]),
],
+132 -109
View File
@@ -59,6 +59,7 @@ class NavigationRailItem extends StatelessWidget {
final bool autofocus;
final BorderRadius borderRadius;
final double iconSize;
final double horizontalPadding;
/// Called when RIGHT arrow is pressed to navigate to content area.
final VoidCallback? onNavigateRight;
@@ -77,6 +78,7 @@ class NavigationRailItem extends StatelessWidget {
this.autofocus = false,
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
this.iconSize = 22,
this.horizontalPadding = 17,
this.onNavigateRight,
});
@@ -108,6 +110,7 @@ class NavigationRailItem extends StatelessWidget {
child: Container(
decoration: BoxDecoration(
color: () {
if (isCollapsed) return isFocused ? t.text.withValues(alpha: 0.12) : null;
if (isSelected && isFocused) return t.text.withValues(alpha: 0.15);
if (isSelected) return t.text.withValues(alpha: 0.1);
if (isFocused) return t.text.withValues(alpha: 0.12);
@@ -123,7 +126,7 @@ class NavigationRailItem extends StatelessWidget {
child: SizedBox(
width: SideNavigationRailState.expandedWidth - 24,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17),
padding: EdgeInsets.symmetric(vertical: 12, horizontal: horizontalPadding),
child: Row(
children: [
AppIcon(
@@ -194,7 +197,7 @@ 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 tvCollapsedWidth = 48.0;
static const double expandedWidth = 220.0;
static const double _horizontalPadding = 12.0;
static const double _itemHorizontalPadding = 17.0;
@@ -203,9 +206,18 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth;
static double itemHorizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) {
if (isCollapsed && PlatformDetector.isTV()) {
return ((tvCollapsedWidth - _defaultIconSize) / 2).clamp(0.0, _itemHorizontalPadding).toDouble();
}
return _itemHorizontalPadding;
}
static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) {
if (!isCollapsed) return _horizontalPadding;
final centeredPadding = ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - _itemHorizontalPadding;
final centeredPadding =
((collapsedWidthForContext(context) - _defaultIconSize) / 2) -
itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
return centeredPadding.clamp(0.0, _horizontalPadding).toDouble();
}
@@ -525,7 +537,9 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
final isCollapsed = !_shouldExpand;
final effectiveCollapsedWidth = collapsedWidthForContext(context);
final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed);
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
final surfaceOpacity = isCollapsed && PlatformDetector.isTV() ? 0.0 : 1.0;
// Listen to fullscreen + groupLibrariesByServer setting so the rail
// rebuilds when the user toggles "Group libraries by server" in Appearance.
@@ -584,118 +598,119 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
curve: Curves.easeOutCubic,
width: isCollapsed ? effectiveCollapsedWidth : expandedWidth,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(color: t.surface),
child: IgnorePointer(
ignoring: isCollapsed,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder),
child: Column(
children: [
SizedBox(height: _getTopPadding(context)),
Expanded(
child: ListView(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
clipBehavior: Clip.hardEdge,
children: [
if (widget.isOfflineMode && widget.onReconnect != null) ...[
_buildReconnectItem(isCollapsed: isCollapsed),
const SizedBox(height: 8),
],
if (!widget.isOfflineMode) ...[
_buildNavItem(
icon: Symbols.home_rounded,
selectedIcon: Symbols.home_rounded,
label: Translations.of(context).common.home,
isSelected: widget.selectedTab == NavigationTabId.discover,
isFocused: _focusTracker.isFocused(_kHome),
onTap: () => widget.onDestinationSelected(NavigationTabId.discover),
focusNode: _focusTracker.get(_kHome),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
_buildLibrariesSection(
visibleRows,
hiddenRows,
hiddenLibraries.length,
t,
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
if (context.watch<MultiServerProvider>().hasLiveTv) ...[
decoration: const BoxDecoration(),
child: Stack(
children: [
Positioned.fill(
child: AnimatedOpacity(
opacity: surfaceOpacity,
duration: t.normal,
curve: Curves.easeOutCubic,
child: ColoredBox(color: t.surface),
),
),
IgnorePointer(
ignoring: isCollapsed,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder),
child: Column(
children: [
SizedBox(height: _getTopPadding(context)),
Expanded(
child: ListView(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
clipBehavior: Clip.hardEdge,
children: [
if (widget.isOfflineMode && widget.onReconnect != null) ...[
_buildReconnectItem(isCollapsed: isCollapsed),
const SizedBox(height: 8),
],
if (!widget.isOfflineMode) ...[
_buildNavItem(
icon: Symbols.home_rounded,
selectedIcon: Symbols.home_rounded,
label: Translations.of(context).common.home,
isSelected: widget.selectedTab == NavigationTabId.discover,
isFocused: _focusTracker.isFocused(_kHome),
onTap: () => widget.onDestinationSelected(NavigationTabId.discover),
focusNode: _focusTracker.get(_kHome),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
_buildLibrariesSection(
visibleRows,
hiddenRows,
hiddenLibraries.length,
t,
isCollapsed: isCollapsed,
itemHorizontalPadding: itemHorizontalPadding,
),
const SizedBox(height: 8),
if (context.watch<MultiServerProvider>().hasLiveTv) ...[
_buildNavItem(
icon: Symbols.live_tv_rounded,
selectedIcon: Symbols.live_tv_rounded,
label: Translations.of(context).navigation.liveTv,
isSelected: widget.selectedTab == NavigationTabId.liveTv,
isFocused: _focusTracker.isFocused('liveTv'),
onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv),
focusNode: _focusTracker.get('liveTv'),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
_buildNavItem(
icon: Symbols.search_rounded,
selectedIcon: Symbols.search_rounded,
label: Translations.of(context).common.search,
isSelected: widget.selectedTab == NavigationTabId.search,
isFocused: _focusTracker.isFocused(_kSearch),
onTap: () => widget.onDestinationSelected(NavigationTabId.search),
focusNode: _focusTracker.get(_kSearch),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
// Downloads (hidden on Apple TV — no user
// file storage)
if (!PlatformDetector.isAppleTV()) ...[
_buildNavItem(
icon: Symbols.download_rounded,
selectedIcon: Symbols.download_rounded,
label: Translations.of(context).navigation.downloads,
isSelected: widget.selectedTab == NavigationTabId.downloads,
isFocused: _focusTracker.isFocused(_kDownloads),
onTap: () => widget.onDestinationSelected(NavigationTabId.downloads),
focusNode: _focusTracker.get(_kDownloads),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
_buildNavItem(
icon: Symbols.live_tv_rounded,
selectedIcon: Symbols.live_tv_rounded,
label: Translations.of(context).navigation.liveTv,
isSelected: widget.selectedTab == NavigationTabId.liveTv,
isFocused: _focusTracker.isFocused('liveTv'),
onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv),
focusNode: _focusTracker.get('liveTv'),
icon: Symbols.settings_rounded,
selectedIcon: Symbols.settings_rounded,
label: Translations.of(context).common.settings,
isSelected: widget.selectedTab == NavigationTabId.settings,
isFocused: _focusTracker.isFocused(_kSettings),
onTap: () => widget.onDestinationSelected(NavigationTabId.settings),
focusNode: _focusTracker.get(_kSettings),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
_buildNavItem(
icon: Symbols.search_rounded,
selectedIcon: Symbols.search_rounded,
label: Translations.of(context).common.search,
isSelected: widget.selectedTab == NavigationTabId.search,
isFocused: _focusTracker.isFocused(_kSearch),
onTap: () => widget.onDestinationSelected(NavigationTabId.search),
focusNode: _focusTracker.get(_kSearch),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
// Downloads (hidden on Apple TV — no user
// file storage)
if (!PlatformDetector.isAppleTV()) ...[
_buildNavItem(
icon: Symbols.download_rounded,
selectedIcon: Symbols.download_rounded,
label: Translations.of(context).navigation.downloads,
isSelected: widget.selectedTab == NavigationTabId.downloads,
isFocused: _focusTracker.isFocused(_kDownloads),
onTap: () => widget.onDestinationSelected(NavigationTabId.downloads),
focusNode: _focusTracker.get(_kDownloads),
isCollapsed: isCollapsed,
),
const SizedBox(height: 8),
],
_buildNavItem(
icon: Symbols.settings_rounded,
selectedIcon: Symbols.settings_rounded,
label: Translations.of(context).common.settings,
isSelected: widget.selectedTab == NavigationTabId.settings,
isFocused: _focusTracker.isFocused(_kSettings),
onTap: () => widget.onDestinationSelected(NavigationTabId.settings),
focusNode: _focusTracker.get(_kSettings),
isCollapsed: isCollapsed,
),
],
),
),
if (_showFullscreenToggle)
Padding(
padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12),
child: _buildFullscreenItem(isCollapsed: isCollapsed),
),
],
),
if (_showFullscreenToggle)
Padding(
padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12),
child: _buildFullscreenItem(isCollapsed: isCollapsed),
),
],
),
),
),
],
),
),
),
@@ -717,6 +732,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
bool autofocus = false,
}) {
final t = tokens(context);
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
return NavigationRailItem(
icon: icon,
@@ -737,6 +753,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
onTap: onTap,
focusNode: focusNode,
autofocus: autofocus,
horizontalPadding: itemHorizontalPadding,
onNavigateRight: widget.onNavigateToContent,
);
}
@@ -744,6 +761,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
Widget _buildReconnectItem({required bool isCollapsed}) {
final t = tokens(context);
final isFocused = _focusTracker.isFocused(_kReconnect);
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
return NavigationRailItem(
icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded,
@@ -761,6 +779,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
// ignore: no-empty-block - no-op tap handler while reconnecting
onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(),
focusNode: _focusTracker.get(_kReconnect),
horizontalPadding: itemHorizontalPadding,
onNavigateRight: widget.onNavigateToContent,
);
}
@@ -769,6 +788,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
final t = tokens(context);
final isFullscreen = FullscreenStateManager().isFullscreen;
final isFocused = _focusTracker.isFocused(_kFullscreen);
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
return NavigationRailItem(
icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded,
@@ -783,6 +803,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
isCollapsed: isCollapsed,
onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()),
focusNode: _focusTracker.get(_kFullscreen),
horizontalPadding: itemHorizontalPadding,
onNavigateRight: widget.onNavigateToContent,
);
}
@@ -793,6 +814,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
int hiddenLibraryCount,
dynamic t, {
bool isCollapsed = false,
required double itemHorizontalPadding,
}) {
final librariesProvider = context.watch<LibrariesProvider>();
final isLoading = librariesProvider.isLoading;
@@ -833,6 +855,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
child: Container(
decoration: BoxDecoration(
color: () {
if (isCollapsed) return isLibrariesFocused ? t.text.withValues(alpha: 0.08) : null;
if (isLibrariesSelected) return t.text.withValues(alpha: 0.1);
if (isLibrariesFocused) return t.text.withValues(alpha: 0.08);
return null;
@@ -847,7 +870,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
child: SizedBox(
width: expandedWidth - 24,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17),
padding: EdgeInsets.symmetric(vertical: 12, horizontal: itemHorizontalPadding),
child: Row(
children: [
AppIcon(
+49 -34
View File
@@ -256,6 +256,7 @@ class TvBrowseRail extends StatefulWidget {
final bool autofocus;
final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub;
final double Function(MediaHub hub)? widePosterScaleForHub;
final double backgroundBleedLeft;
const TvBrowseRail({
super.key,
@@ -279,6 +280,7 @@ class TvBrowseRail extends StatefulWidget {
this.autofocus = false,
this.episodePosterModeForHub,
this.widePosterScaleForHub,
this.backgroundBleedLeft = 0,
});
@override
@@ -781,43 +783,56 @@ class TvBrowseRailState extends State<TvBrowseRail> {
heightFactor: 1,
child: SizedBox(
height: totalHeight,
child: Container(
padding: EdgeInsets.fromLTRB(
horizontalInset,
TvBrowseRailLayout.railTopPaddingForScale(scale),
0,
TvBrowseRailLayout.railBottomPaddingForScale(scale),
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)],
),
),
child: AnimatedOpacity(
opacity: hasFocus ? 1 : 0.6,
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: ClipRect(
clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0),
child: SizedBox(
height: viewportHeight,
child: _buildHubSectionList(
context,
hasFocus: hasFocus,
modes: modes,
metricsByHub: metricsByHub,
sectionHeights: sectionHeights,
scale: scale,
leftOverflow: horizontalInset,
interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth,
bottomPadding: bottomPadding,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
bottom: 0,
left: -widget.backgroundBleedLeft,
right: 0,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)],
),
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
horizontalInset,
TvBrowseRailLayout.railTopPaddingForScale(scale),
0,
TvBrowseRailLayout.railBottomPaddingForScale(scale),
),
child: AnimatedOpacity(
opacity: hasFocus ? 1 : 0.6,
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: ClipRect(
clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0),
child: SizedBox(
height: viewportHeight,
child: _buildHubSectionList(
context,
hasFocus: hasFocus,
modes: modes,
metricsByHub: metricsByHub,
sectionHeights: sectionHeights,
scale: scale,
leftOverflow: horizontalInset,
interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth,
bottomPadding: bottomPadding,
),
),
),
),
),
],
),
),
),
+14 -10
View File
@@ -59,16 +59,7 @@ class TvSpotlightBackground extends StatelessWidget {
fit: StackFit.expand,
children: [
if (media != null) _buildArtwork(context, media) else ColoredBox(color: bgColor),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent],
stops: const [0.0, 0.56, 1.0],
),
),
),
_buildHorizontalScrim(context, bgColor),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -150,6 +141,19 @@ class TvSpotlightBackground extends StatelessWidget {
);
}
Widget _buildHorizontalScrim(BuildContext context, Color bgColor) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent],
stops: const [0.0, 0.56, 1.0],
),
),
);
}
Widget _buildInfo(BuildContext context, MediaItem media) {
final scale = _scale(context);
final shouldHideSpoiler = hideSpoilers && media.shouldHideSpoiler;
@@ -13,6 +13,8 @@ import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:provider/provider.dart';
@@ -60,9 +62,66 @@ void main() {
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('closed TV rail is slim and keeps primary icons centered', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
await SettingsService.getInstance();
final librariesProvider = LibrariesProvider();
addTearDown(librariesProvider.dispose);
final hiddenLibrariesProvider = HiddenLibrariesProvider();
await hiddenLibrariesProvider.ensureInitialized();
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
addTearDown(multiServerProvider.dispose);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
],
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SideNavigationRail(
selectedTab: NavigationTabId.discover,
isSidebarFocused: false,
alwaysExpanded: false,
onDestinationSelected: (_) {},
onLibrarySelected: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
final rail = find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedContainer)).first;
expect(tester.getSize(rail).width, SideNavigationRailState.tvCollapsedWidth);
final firstIconCenter = tester.getCenter(find.byType(AppIcon).first).dx;
expect(firstIconCenter - tester.getTopLeft(rail).dx, closeTo(SideNavigationRailState.tvCollapsedWidth / 2, 0.1));
final selectedItem = find.byType(NavigationRailItem).first;
final selectedItemContainer = tester.widget<Container>(
find.descendant(of: selectedItem, matching: find.byType(Container)).first,
);
expect((selectedItemContainer.decoration as BoxDecoration?)?.color, isNull);
});
testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async {
await SettingsService.getInstance();