fix(tv): push sidebar content without resizing

This commit is contained in:
edde746
2026-05-22 01:38:29 +02:00
parent 2ebd730201
commit afc94a50fc
11 changed files with 209 additions and 98 deletions
+5 -4
View File
@@ -1526,10 +1526,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<DiscoverScreen>
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<DiscoverScreen>
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(),
@@ -306,10 +306,12 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
final railHeight = tvHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
size: size,
size: railSize,
hubs: tvHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
@@ -336,7 +338,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
top: 0,
bottom: 0,
left: -sidebarBleed,
width: size.width,
width: fullBleedWidth,
child: TvSpotlightBackground(
item: spotlight,
client: client,
@@ -366,7 +368,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
onBack: widget.onBack,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
backgroundBleedLeft: sidebarBleed,
visibleRightInset: sidebarBleed,
),
),
],
+66 -20
View File
@@ -61,6 +61,9 @@ class MainScreenFocusScope extends InheritedWidget {
final VoidCallback focusContent;
final bool isSidebarFocused;
final double sideNavigationWidth;
final double? reservedSideNavigationWidth;
final double? foregroundWidth;
final double? viewportWidth;
final void Function(String libraryGlobalKey)? selectLibrary;
const MainScreenFocusScope({
@@ -69,6 +72,9 @@ class MainScreenFocusScope extends InheritedWidget {
required this.focusContent,
required this.isSidebarFocused,
required this.sideNavigationWidth,
this.reservedSideNavigationWidth,
this.foregroundWidth,
this.viewportWidth,
this.selectLibrary,
required super.child,
});
@@ -83,16 +89,45 @@ class MainScreenFocusScope extends InheritedWidget {
return 0.0;
}
static double clippedContentRightInsetOf(BuildContext context) {
return of(context)?.sideNavigationWidth ?? 0.0;
static double foregroundWidthOf(BuildContext context) {
final width = of(context)?.foregroundWidth;
if (width != null && width > 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<MainScreen>
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<MainScreen>
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<MainScreen>
),
),
],
);
},
),
),
),
),
);
},
);
},
),
+22 -26
View File
@@ -136,7 +136,6 @@ class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> 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),
]),
),
],
),
+1 -3
View File
@@ -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);
+2 -10
View File
@@ -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<HubSection> 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<HubSection> with MountedSetStateMixin {
index,
itemExtent: _itemExtent,
leadingPadding: _leadingPadding,
visibleTrailingInset: _visibleRightInset,
animate: animate,
);
}
@@ -487,13 +484,8 @@ class HubSectionState extends State<HubSection> 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;
+1 -12
View File
@@ -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,
);
}
+6 -17
View File
@@ -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<TvBrowseRail> {
_itemIndex,
itemExtent: _itemExtent,
leadingPadding: _railLeadingPadding,
visibleTrailingInset: widget.visibleRightInset,
animate: animate,
);
}
@@ -655,14 +650,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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;
+12 -3
View File
@@ -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<TvBrowseRail>(find.byType(TvBrowseRail));
expect(browseRail.backgroundBleedLeft, sidebarOffset);
expect(browseRail.visibleRightInset, sidebarOffset);
final backgroundPosition = tester.widget<Positioned>(
find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first,
+42
View File
@@ -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);
});
}
+48
View File
@@ -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<MultiServerProvider>(
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<Positioned>(
find.ancestor(of: gradient.first, matching: find.byType(Positioned)).first,
);
expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
expect(backgroundPosition.width, 1280);
});
}
ScrollPosition _activeRailPosition(WidgetTester tester) {