feat(tv): push content beside sidebar

This commit is contained in:
edde746
2026-05-21 21:01:14 +02:00
parent eaf0f4fa1a
commit 0dd7d7c522
6 changed files with 273 additions and 71 deletions
+4 -4
View File
@@ -41,7 +41,6 @@ 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';
@@ -1535,9 +1534,10 @@ 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);
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
return Material(
color: theme.scaffoldBackgroundColor,
@@ -16,7 +16,6 @@ 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';
@@ -321,9 +320,10 @@ 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);
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
return Material(
color: theme.scaffoldBackgroundColor,
+81 -56
View File
@@ -60,6 +60,7 @@ class MainScreenFocusScope extends InheritedWidget {
final VoidCallback focusSidebar;
final VoidCallback focusContent;
final bool isSidebarFocused;
final double sideNavigationWidth;
final void Function(String libraryGlobalKey)? selectLibrary;
const MainScreenFocusScope({
@@ -67,6 +68,7 @@ class MainScreenFocusScope extends InheritedWidget {
required this.focusSidebar,
required this.focusContent,
required this.isSidebarFocused,
required this.sideNavigationWidth,
this.selectLibrary,
required super.child,
});
@@ -75,9 +77,15 @@ class MainScreenFocusScope extends InheritedWidget {
return context.dependOnInheritedWidgetOfExactType<MainScreenFocusScope>();
}
static double sideNavigationBleedOf(BuildContext context, {required bool alwaysKeepSidebarOpen}) {
final width = of(context)?.sideNavigationWidth;
if (width != null) return width;
return alwaysKeepSidebarOpen ? 0.0 : SideNavigationRailState.collapsedWidthForContext(context);
}
@override
bool updateShouldNotify(MainScreenFocusScope oldWidget) {
return isSidebarFocused != oldWidget.isSidebarFocused;
return isSidebarFocused != oldWidget.isSidebarFocused || sideNavigationWidth != oldWidget.sideNavigationWidth;
}
}
@@ -150,6 +158,7 @@ class _MainScreenState extends State<MainScreen>
final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar');
final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content');
bool _isSidebarFocused = false;
bool _isSidebarInteractionExpanded = false;
/// The binder is now owned by a top-level [Provider] (see main.dart) so
/// the splash can await its first settle before navigating here. We just
@@ -893,6 +902,18 @@ class _MainScreenState extends State<MainScreen>
});
}
void _handleSidebarInteractionExpandedChanged(bool expanded) {
if (_isSidebarInteractionExpanded == expanded) return;
setState(() => _isSidebarInteractionExpanded = expanded);
}
double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) {
final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded;
return isExpanded
? SideNavigationRailState.expandedWidth
: SideNavigationRailState.collapsedWidthForContext(context);
}
/// Suppress stray back events after a child route pops.
/// On Android TV the platform popRoute can arrive before the key events,
/// so BackKeySuppressorObserver misses them and they leak into _handleBackKey.
@@ -1211,9 +1232,7 @@ class _MainScreenState extends State<MainScreen>
return SettingValueBuilder<bool>(
pref: SettingsService.alwaysKeepSidebarOpen,
builder: (context, alwaysExpanded, _) {
final contentLeftPadding = alwaysExpanded
? SideNavigationRailState.expandedWidth
: SideNavigationRailState.collapsedWidthForContext(context);
final targetContentLeftPadding = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded);
return OverlaySheetHost(
child: PopScope(
@@ -1228,59 +1247,65 @@ class _MainScreenState extends State<MainScreen>
if (searchResult == KeyEventResult.handled) return searchResult;
return _handleBackKey(event);
},
child: MainScreenFocusScope(
focusSidebar: _focusSidebar,
focusContent: _focusContent,
isSidebarFocused: _isSidebarFocused,
selectLibrary: _selectLibrary,
child: SideNavigationScope(
child: Stack(
children: [
// Content with animated left padding based on sidebar state
Positioned.fill(
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(left: contentLeftPadding),
child: FocusScope(
node: _contentFocusScope,
// No autofocus - we control focus programmatically to prevent
// autofocus from stealing focus back after setState() rebuilds
child: _buildTickerAwareStack(),
),
),
),
// Sidebar overlays content when expanded (unless always expanded)
Positioned(
top: 0,
bottom: 0,
left: 0,
child: FocusScope(
node: _sidebarFocusScope,
child: SideNavigationRail(
key: _sideNavKey,
selectedTab: _currentTab,
selectedLibraryKey: _selectedLibraryGlobalKey,
isOfflineMode: _isOffline,
isSidebarFocused: _isSidebarFocused,
alwaysExpanded: alwaysExpanded,
isReconnecting: _isReconnecting,
onDestinationSelected: (tab) {
_selectTab(tab);
_focusContent();
},
onLibrarySelected: (key) {
_selectLibrary(key);
_focusContent();
},
onNavigateToContent: _focusContent,
onReconnect: _triggerReconnect,
),
),
),
],
),
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
tween: Tween<double>(end: targetContentLeftPadding),
child: FocusScope(
node: _contentFocusScope,
// No autofocus - we control focus programmatically to prevent
// autofocus from stealing focus back after setState() rebuilds
child: _buildTickerAwareStack(),
),
builder: (context, contentLeftPadding, contentChild) {
return MainScreenFocusScope(
focusSidebar: _focusSidebar,
focusContent: _focusContent,
isSidebarFocused: _isSidebarFocused,
sideNavigationWidth: contentLeftPadding,
selectLibrary: _selectLibrary,
child: SideNavigationScope(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: EdgeInsets.only(left: contentLeftPadding),
child: contentChild!,
),
),
Positioned(
top: 0,
bottom: 0,
left: 0,
child: FocusScope(
node: _sidebarFocusScope,
child: SideNavigationRail(
key: _sideNavKey,
selectedTab: _currentTab,
selectedLibraryKey: _selectedLibraryGlobalKey,
isOfflineMode: _isOffline,
isSidebarFocused: _isSidebarFocused,
alwaysExpanded: alwaysExpanded,
isReconnecting: _isReconnecting,
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
onDestinationSelected: (tab) {
_selectTab(tab);
_focusContent();
},
onLibrarySelected: (key) {
_selectLibrary(key);
_focusContent();
},
onNavigateToContent: _focusContent,
onReconnect: _triggerReconnect,
),
),
),
],
),
),
);
},
),
),
),
+42 -6
View File
@@ -169,6 +169,9 @@ class SideNavigationRail extends StatefulWidget {
/// Called when RIGHT arrow is pressed to navigate to content without selecting.
final VoidCallback? onNavigateToContent;
/// Called when hover/touch expansion changes, so the shell can reserve width.
final ValueChanged<bool>? onInteractionExpandedChanged;
/// Called when the user taps the reconnect button in offline mode.
final VoidCallback? onReconnect;
@@ -183,6 +186,7 @@ class SideNavigationRail extends StatefulWidget {
required this.onDestinationSelected,
required this.onLibrarySelected,
this.onNavigateToContent,
this.onInteractionExpandedChanged,
this.onReconnect,
});
@@ -195,6 +199,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
bool _isHovered = false;
bool _isTouchExpanded = false;
bool _lastReportedInteractionExpanded = false;
Timer? _collapseTimer;
static const double collapsedWidth = 80.0;
static const double tvCollapsedWidth = 48.0;
@@ -241,6 +246,8 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
/// Whether the sidebar should be expanded (always, hover, or focus)
bool get _shouldExpand => widget.alwaysExpanded || _isHovered || _isTouchExpanded || widget.isSidebarFocused;
bool get _interactionExpanded => _isHovered || _isTouchExpanded;
/// macOS has the system green button; mobile/TV have no OS fullscreen toggle.
bool get _showFullscreenToggle => Platform.isWindows || Platform.isLinux;
@@ -268,16 +275,36 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
super.didUpdateWidget(oldWidget);
// Auto-collapse after navigation (selection changed)
if (oldWidget.selectedTab != widget.selectedTab || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) {
final wasInteractionExpanded = _interactionExpanded;
_isTouchExpanded = false;
if (wasInteractionExpanded != _interactionExpanded) {
_scheduleInteractionExpandedNotification();
}
}
}
void _scheduleInteractionExpandedNotification() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_notifyInteractionExpandedIfNeeded();
});
}
void _notifyInteractionExpandedIfNeeded() {
final expanded = _interactionExpanded;
if (_lastReportedInteractionExpanded == expanded) return;
_lastReportedInteractionExpanded = expanded;
widget.onInteractionExpandedChanged?.call(expanded);
}
void _onHoverEnter() {
_collapseTimer?.cancel();
_isTouchExpanded = false; // Mouse takes over
if (!_isHovered) {
setState(() => _isHovered = true);
}
if (_isHovered && !_isTouchExpanded) return;
setState(() {
_isTouchExpanded = false; // Mouse takes over
_isHovered = true;
});
_notifyInteractionExpandedIfNeeded();
}
void _onHoverExit() {
@@ -285,10 +312,17 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
_collapseTimer = Timer(_collapseDelay, () {
if (mounted && _isHovered) {
setState(() => _isHovered = false);
_notifyInteractionExpandedIfNeeded();
}
});
}
void _expandForTouch() {
if (_isTouchExpanded) return;
setState(() => _isTouchExpanded = true);
_notifyInteractionExpandedIfNeeded();
}
/// The key of the last focused sidebar item (for pre-capture before focus shifts).
String? get lastFocusedKey => _focusTracker.lastFocusedKey;
@@ -472,6 +506,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
void collapse() {
if (_isTouchExpanded) {
setState(() => _isTouchExpanded = false);
_notifyInteractionExpandedIfNeeded();
}
}
@@ -539,7 +574,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
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;
final surfaceOpacity = 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,6 +619,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
onTapOutside: (_) {
if (_isTouchExpanded) {
setState(() => _isTouchExpanded = false);
_notifyInteractionExpandedIfNeeded();
}
},
child: MouseRegion(
@@ -592,7 +628,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: isCollapsed ? () => setState(() => _isTouchExpanded = true) : null,
onTap: isCollapsed ? _expandForTouch : null,
child: AnimatedContainer(
duration: t.normal,
curve: Curves.easeOutCubic,
+25 -1
View File
@@ -24,14 +24,18 @@ import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/discover_screen.dart';
import 'package:plezy/screens/main_screen.dart';
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/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/layout_constants.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/watch_together.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:plezy/widgets/tv_browse_rail.dart';
import 'package:plezy/widgets/tv_spotlight_background.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
@@ -113,7 +117,13 @@ void main() {
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
child: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)),
),
),
),
),
@@ -122,6 +132,20 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(TvBrowseRail), findsOneWidget);
final scale = TvLayoutConstants.scaleForSize(const Size(1280, 720));
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
final spotlightBackground = tester.widget<TvSpotlightBackground>(find.byType(TvSpotlightBackground));
expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + SideNavigationRailState.expandedWidth, 0.001));
expect(
tester.widget<TvBrowseRail>(find.byType(TvBrowseRail)).backgroundBleedLeft,
SideNavigationRailState.expandedWidth,
);
final backgroundPosition = tester.widget<Positioned>(
find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first,
);
expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
tester.state<FocusableActionBarState>(find.byType(FocusableActionBar)).requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
+117
View File
@@ -1,3 +1,5 @@
import 'dart:ui' show PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -122,6 +124,121 @@ void main() {
expect((selectedItemContainer.decoration as BoxDecoration?)?.color, isNull);
});
testWidgets('expanded TV rail keeps its surface transparent', (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: true,
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.expandedWidth);
final surfaceOpacity = tester
.widgetList<AnimatedOpacity>(
find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedOpacity)),
)
.singleWhere((widget) => widget.child is ColoredBox);
expect(surfaceOpacity.opacity, 0.0);
});
testWidgets('reports interaction expansion for shell content push', (tester) async {
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);
final reports = <bool>[];
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,
onInteractionExpandedChanged: reports.add,
onDestinationSelected: (_) {},
onLibrarySelected: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
final rail = find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedContainer)).first;
expect(tester.getSize(rail).width, SideNavigationRailState.collapsedWidth);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(location: const Offset(799, 599));
await tester.pump();
await gesture.moveTo(tester.getCenter(rail));
await tester.pumpAndSettle();
expect(reports.last, isTrue);
expect(tester.getSize(rail).width, SideNavigationRailState.expandedWidth);
await gesture.moveTo(tester.getBottomRight(rail) + const Offset(100, -10));
await tester.pump(const Duration(milliseconds: 200));
expect(reports.last, isFalse);
});
testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async {
await SettingsService.getInstance();