feat(tv): refine sidebar backdrop
This commit is contained in:
@@ -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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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]),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user