From afc94a50fc7266049bbf27d1b7463db1452d239a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 22 May 2026 01:38:29 +0200 Subject: [PATCH] fix(tv): push sidebar content without resizing --- lib/screens/discover_screen.dart | 9 +- .../tabs/library_recommended_tab.dart | 7 +- lib/screens/main_screen.dart | 86 ++++++++++++++----- lib/screens/settings/settings_screen.dart | 48 +++++------ lib/utils/scroll_utils.dart | 4 +- lib/widgets/hub_section.dart | 12 +-- lib/widgets/settings_page.dart | 13 +-- lib/widgets/tv_browse_rail.dart | 23 ++--- test/screens/discover_screen_test.dart | 15 +++- test/screens/main_screen_layout_test.dart | 42 +++++++++ test/widgets/tv_browse_rail_test.dart | 48 +++++++++++ 11 files changed, 209 insertions(+), 98 deletions(-) create mode 100644 test/screens/main_screen_layout_test.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index deeb40e9..a7e1c162 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1526,10 +1526,12 @@ class _DiscoverScreenState extends State context, alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen), ); + final railSize = MainScreenFocusScope.foregroundSizeOf(context); + final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); final railHeight = browseHubs.isEmpty ? 0.0 : TvBrowseRailLayout.estimateHeight( - size: size, + size: railSize, hubs: browseHubs, density: svc.read(SettingsService.libraryDensity), episodePosterMode: svc.read(SettingsService.episodePosterMode), @@ -1554,7 +1556,7 @@ class _DiscoverScreenState extends State top: 0, bottom: 0, left: -sidebarBleed, - width: size.width, + width: fullBleedWidth, child: TvSpotlightBackground( item: spotlight, client: _getMediaClientForItem(spotlight), @@ -1613,13 +1615,12 @@ class _DiscoverScreenState extends State onNavigateToSidebar: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, backgroundBleedLeft: sidebarBleed, - visibleRightInset: sidebarBleed, ), ), Positioned( top: 0, left: -sidebarBleed, - width: size.width, + width: fullBleedWidth, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), ), if (_switchingProfile) const ProfileSwitchingOverlay(), diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 9741802b..cbd4b135 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -306,10 +306,12 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState 0) return width; + return MediaQuery.sizeOf(context).width; + } + + static Size foregroundSizeOf(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return Size(foregroundWidthOf(context), size.height); + } + + static double fullBleedWidthOf(BuildContext context) { + final width = of(context)?.viewportWidth; + if (width != null && width > 0) return width; + return MediaQuery.sizeOf(context).width; } @override bool updateShouldNotify(MainScreenFocusScope oldWidget) { - return isSidebarFocused != oldWidget.isSidebarFocused || sideNavigationWidth != oldWidget.sideNavigationWidth; + return isSidebarFocused != oldWidget.isSidebarFocused || + sideNavigationWidth != oldWidget.sideNavigationWidth || + reservedSideNavigationWidth != oldWidget.reservedSideNavigationWidth || + foregroundWidth != oldWidget.foregroundWidth || + viewportWidth != oldWidget.viewportWidth; } } +@visibleForTesting +({double left, double width}) mainScreenSideNavigationContentLayout({ + required double viewportWidth, + required double currentSideNavigationWidth, + required double reservedSideNavigationWidth, +}) { + return ( + left: currentSideNavigationWidth, + width: (viewportWidth - reservedSideNavigationWidth).clamp(0.0, double.infinity).toDouble(), + ); +} + @visibleForTesting bool shouldRetryActiveProfileBindAfterReconnect({ required bool hasActiveProfile, @@ -1237,6 +1272,9 @@ class _MainScreenState extends State pref: SettingsService.alwaysKeepSidebarOpen, builder: (context, alwaysExpanded, _) { final targetContentOffset = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded); + final reservedContentOffset = alwaysExpanded + ? SideNavigationRailState.expandedWidth + : SideNavigationRailState.collapsedWidthForContext(context); return OverlaySheetHost( child: PopScope( @@ -1262,24 +1300,32 @@ class _MainScreenState extends State child: _buildTickerAwareStack(), ), builder: (context, contentLeftPadding, contentChild) { - return MainScreenFocusScope( - focusSidebar: _focusSidebar, - focusContent: _focusContent, - isSidebarFocused: _isSidebarFocused, - sideNavigationWidth: contentLeftPadding, - selectLibrary: _selectLibrary, - child: SideNavigationScope( - child: LayoutBuilder( - builder: (context, constraints) { - final contentWidth = constraints.maxWidth; - return Stack( + return LayoutBuilder( + builder: (context, constraints) { + final viewportWidth = constraints.maxWidth; + final contentLayout = mainScreenSideNavigationContentLayout( + viewportWidth: viewportWidth, + currentSideNavigationWidth: contentLeftPadding, + reservedSideNavigationWidth: reservedContentOffset, + ); + return MainScreenFocusScope( + focusSidebar: _focusSidebar, + focusContent: _focusContent, + isSidebarFocused: _isSidebarFocused, + sideNavigationWidth: contentLeftPadding, + reservedSideNavigationWidth: reservedContentOffset, + foregroundWidth: contentLayout.width, + viewportWidth: viewportWidth, + selectLibrary: _selectLibrary, + child: SideNavigationScope( + child: Stack( clipBehavior: Clip.hardEdge, children: [ Positioned( top: 0, bottom: 0, - left: contentLeftPadding, - width: contentWidth, + left: contentLayout.left, + width: contentLayout.width, child: contentChild!, ), Positioned( @@ -1311,10 +1357,10 @@ class _MainScreenState extends State ), ), ], - ); - }, - ), - ), + ), + ), + ); + }, ); }, ), diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index d8fd4cc2..f9b614e9 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -136,7 +136,6 @@ class _SettingsScreenState extends State with FocusableTab, Moun @override Widget build(BuildContext context) { - final rightInset = MainScreenFocusScope.clippedContentRightInsetOf(context); return Scaffold( body: Focus( onKeyEvent: _handleKeyEvent, @@ -144,42 +143,39 @@ class _SettingsScreenState extends State with FocusableTab, Moun primary: false, slivers: [ ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), - SliverPadding( - padding: EdgeInsets.only(right: rightInset), - sliver: SliverList( - delegate: SliverChildListDelegate([ - if (DonationService.isEnabled) _buildDonateTile(), + SliverList( + delegate: SliverChildListDelegate([ + if (DonationService.isEnabled) _buildDonateTile(), - _buildAppearanceTile(), + _buildAppearanceTile(), - _buildPlaybackTile(), + _buildPlaybackTile(), - _buildTrackersTile(), + _buildTrackersTile(), - _buildConnectionsSection(), + _buildConnectionsSection(), - _buildProfilesSection(), + _buildProfilesSection(), - if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), + if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), - if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()], + if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()], - _buildAdvancedSection(), + _buildAdvancedSection(), - if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], + if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], - if (!PlatformDetector.isTV()) _buildBackupSection(), + if (!PlatformDetector.isTV()) _buildBackupSection(), - SettingNavigationTile( - focusNode: _focusTracker.get(_kAbout), - icon: Symbols.info_rounded, - title: t.settings.about, - subtitle: t.settings.aboutDescription, - destinationBuilder: (context) => const AboutScreen(), - ), - const SizedBox(height: 24), - ]), - ), + SettingNavigationTile( + focusNode: _focusTracker.get(_kAbout), + icon: Symbols.info_rounded, + title: t.settings.about, + subtitle: t.settings.aboutDescription, + destinationBuilder: (context) => const AboutScreen(), + ), + const SizedBox(height: 24), + ]), ), ], ), diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index de7d50e1..7cee80a4 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -59,7 +59,6 @@ void scrollListToIndex( int index, { required double itemExtent, double leadingPadding = 12.0, - double visibleTrailingInset = 0.0, bool animate = true, }) { if (controller.positions.length != 1 || itemExtent <= 0) return; @@ -68,8 +67,7 @@ void scrollListToIndex( final maxExtent = controller.position.maxScrollExtent; if (!viewport.isFinite || !maxExtent.isFinite) return; final targetCenter = leadingPadding + (index * itemExtent) + (itemExtent / 2); - final visibleViewport = (viewport - visibleTrailingInset).clamp(1.0, double.infinity).toDouble(); - final desiredOffset = (targetCenter - (visibleViewport / 2)).clamp(0.0, maxExtent); + final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, maxExtent); if (animate) { controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index cb2184c2..16dec70c 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -19,7 +19,6 @@ import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../screens/hub_detail_screen.dart'; -import '../screens/main_screen.dart'; import '../utils/media_navigation_helper.dart'; import 'focus_builders.dart'; import 'media_card.dart'; @@ -107,7 +106,6 @@ class HubSectionState extends State with MountedSetStateMixin { ? TvLayoutConstants.shelfHorizontalInset : 12.0; double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV()); - double get _visibleRightInset => MainScreenFocusScope.clippedContentRightInsetOf(context); Timer? _longPressTimer; bool _isSelectKeyDown = false; @@ -211,7 +209,6 @@ class HubSectionState extends State with MountedSetStateMixin { index, itemExtent: _itemExtent, leadingPadding: _leadingPadding, - visibleTrailingInset: _visibleRightInset, animate: animate, ); } @@ -487,13 +484,8 @@ class HubSectionState extends State with MountedSetStateMixin { scrollDirection: Axis.horizontal, clipBehavior: Clip.none, padding: widget.inset - ? EdgeInsets.fromLTRB(0, isTv ? 6 : 2, _visibleRightInset + 4, isTv ? 6 : 2) - : EdgeInsets.fromLTRB( - isTv ? leadingPadding : 8, - isTv ? 6 : 2, - (isTv ? leadingPadding : 8) + _visibleRightInset, - isTv ? 6 : 2, - ), + ? EdgeInsets.symmetric(vertical: isTv ? 6 : 2) + : EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2), itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length, itemBuilder: (context, index) { final isItemFocused = hasFocus && index == _focusedIndex; diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index bec971de..c3b4f59d 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../screens/main_screen.dart'; import 'focused_scroll_scaffold.dart'; /// Standard scaffold for settings pages made of ordinary list rows. @@ -39,23 +38,13 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { final pageSlivers = slivers ?? [_buildListSliver()]; - final rightInset = MainScreenFocusScope.clippedContentRightInsetOf(context); - final insetSlivers = rightInset == 0 - ? pageSlivers - : [ - for (final sliver in pageSlivers) - SliverPadding( - padding: EdgeInsets.only(right: rightInset), - sliver: sliver, - ), - ]; return FocusedScrollScaffold( title: title, actions: actions, pinned: pinned, automaticallyImplyLeading: automaticallyImplyLeading, onBackPressed: onBackPressed, - slivers: insetSlivers, + slivers: pageSlivers, ); } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index d248c5a3..825bb839 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -181,11 +182,10 @@ class TvBrowseRailLayout { required TvBrowseRailLayoutMetrics metrics, required double viewportWidth, required double scale, - double visibleTrailingInset = 0.0, }) { 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 + visibleTrailingInset; + final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth; return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); } @@ -194,12 +194,10 @@ class TvBrowseRailLayout { required TvBrowseRailLayoutMetrics metrics, required double viewportWidth, required double maxScrollExtent, - double visibleTrailingInset = 0.0, }) { final itemExtent = metrics.cardWidth + metrics.itemGap; final targetCenter = metrics.railEdgePadding + (index * itemExtent) + (itemExtent / 2); - final visibleViewport = (viewportWidth - visibleTrailingInset).clamp(1.0, double.infinity).toDouble(); - return (targetCenter - (visibleViewport / 2)).clamp(0.0, maxScrollExtent).toDouble(); + return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble(); } static double estimateHeight({ @@ -260,7 +258,6 @@ class TvBrowseRail extends StatefulWidget { final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub; final double Function(MediaHub hub)? widePosterScaleForHub; final double backgroundBleedLeft; - final double visibleRightInset; const TvBrowseRail({ super.key, @@ -285,7 +282,6 @@ class TvBrowseRail extends StatefulWidget { this.episodePosterModeForHub, this.widePosterScaleForHub, this.backgroundBleedLeft = 0, - this.visibleRightInset = 0, }); @override @@ -630,7 +626,6 @@ class TvBrowseRailState extends State { _itemIndex, itemExtent: _itemExtent, leadingPadding: _railLeadingPadding, - visibleTrailingInset: widget.visibleRightInset, animate: animate, ); } @@ -655,14 +650,12 @@ class TvBrowseRailState extends State { metrics: metrics, viewportWidth: viewportWidth, scale: scale, - visibleTrailingInset: widget.visibleRightInset, ); final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex( index: initialItemIndex, metrics: metrics, viewportWidth: viewportWidth, maxScrollExtent: maxScrollExtent, - visibleTrailingInset: widget.visibleRightInset, ); return ScrollController(initialScrollOffset: initialScrollOffset); }); @@ -733,6 +726,7 @@ class TvBrowseRailState extends State { scale, ).clamp(0.0, horizontalInset).toDouble(); final width = constraints.maxWidth.isFinite ? constraints.maxWidth : MediaQuery.sizeOf(context).width; + final backgroundWidth = math.max(width + widget.backgroundBleedLeft, MediaQuery.sizeOf(context).width); final availableWidth = (width - horizontalInset).clamp(1.0, double.infinity).toDouble(); final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble(); final density = svc.read(SettingsService.libraryDensity); @@ -798,7 +792,7 @@ class TvBrowseRailState extends State { top: 0, bottom: 0, left: -widget.backgroundBleedLeft, - width: width, + width: backgroundWidth, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -987,12 +981,7 @@ class TvBrowseRailState extends State { controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, - padding: EdgeInsets.fromLTRB( - metrics.railEdgePadding, - 2 * scale, - metrics.railEdgePadding + widget.visibleRightInset, - 6 * scale, - ), + padding: EdgeInsets.fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), itemCount: totalCount, itemBuilder: (context, itemIndex) { final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex; diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index f6da1c10..db248530 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -131,7 +131,17 @@ void main() { focusContent: () {}, isSidebarFocused: false, sideNavigationWidth: sidebarOffset, - child: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)), + reservedSideNavigationWidth: sidebarOffset, + foregroundWidth: 1280 - sidebarOffset, + viewportWidth: 1280, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 1280 - sidebarOffset, + height: 720, + child: DiscoverScreen(key: discoverKey), + ), + ), ), ), ), @@ -147,7 +157,7 @@ void main() { expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + sidebarOffset, 0.001)); final railHeight = TvBrowseRailLayout.estimateHeight( - size: const Size(1280, 720), + size: const Size(1280 - sidebarOffset, 720), hubs: [hub], density: LibraryDensity.max, episodePosterMode: settings.read(SettingsService.episodePosterMode), @@ -168,7 +178,6 @@ void main() { final browseRail = tester.widget(find.byType(TvBrowseRail)); expect(browseRail.backgroundBleedLeft, sidebarOffset); - expect(browseRail.visibleRightInset, sidebarOffset); final backgroundPosition = tester.widget( find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first, diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart new file mode 100644 index 00000000..0d8420e3 --- /dev/null +++ b/test/screens/main_screen_layout_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/main_screen.dart'; +import 'package:plezy/widgets/side_navigation_rail.dart'; + +void main() { + test('side navigation pushes stable foreground off-screen while temporarily expanded', () { + const viewportWidth = 1280.0; + const reservedWidth = SideNavigationRailState.tvCollapsedWidth; + + final collapsed = mainScreenSideNavigationContentLayout( + viewportWidth: viewportWidth, + currentSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth, + reservedSideNavigationWidth: reservedWidth, + ); + final expanded = mainScreenSideNavigationContentLayout( + viewportWidth: viewportWidth, + currentSideNavigationWidth: SideNavigationRailState.expandedWidth, + reservedSideNavigationWidth: reservedWidth, + ); + + expect(collapsed.width, viewportWidth - SideNavigationRailState.tvCollapsedWidth); + expect(expanded.width, collapsed.width); + expect(collapsed.left, SideNavigationRailState.tvCollapsedWidth); + expect(expanded.left, SideNavigationRailState.expandedWidth); + expect(collapsed.left + collapsed.width, viewportWidth); + expect(expanded.left + expanded.width, viewportWidth + SideNavigationRailState.expandedWidth - reservedWidth); + }); + + test('side navigation reserves expanded width when always open', () { + const viewportWidth = 1280.0; + + final expanded = mainScreenSideNavigationContentLayout( + viewportWidth: viewportWidth, + currentSideNavigationWidth: SideNavigationRailState.expandedWidth, + reservedSideNavigationWidth: SideNavigationRailState.expandedWidth, + ); + + expect(expanded.left, SideNavigationRailState.expandedWidth); + expect(expanded.width, viewportWidth - SideNavigationRailState.expandedWidth); + expect(expanded.left + expanded.width, viewportWidth); + }); +} diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 5a961100..1d049d36 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -12,6 +12,7 @@ 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_theme.dart'; +import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:provider/provider.dart'; @@ -648,6 +649,53 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('background gradient stays full bleed beside pushed foreground', (tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final serverManager = MultiServerManager(); + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1060, + height: 720, + child: TvBrowseRail( + hubs: [hub], + iconForHub: (_, _) => Icons.movie_rounded, + backgroundBleedLeft: SideNavigationRailState.expandedWidth, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final gradient = find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).gradient is LinearGradient, + ); + final backgroundPosition = tester.widget( + find.ancestor(of: gradient.first, matching: find.byType(Positioned)).first, + ); + + expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth); + expect(backgroundPosition.width, 1280); + }); } ScrollPosition _activeRailPosition(WidgetTester tester) {