fix(tv): displace content beside sidebar

This commit is contained in:
edde746
2026-05-22 00:14:16 +02:00
parent 0dd7d7c522
commit 2ebd730201
13 changed files with 407 additions and 113 deletions
+45 -7
View File
@@ -13,6 +13,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/mixins/refreshable.dart';
import 'package:plezy/mixins/tab_visibility_aware.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
@@ -55,7 +56,14 @@ void main() {
});
testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async {
await SettingsService.getInstance();
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.libraryDensity, LibraryDensity.max);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final item = MediaItem(
id: 'movie_1',
@@ -92,6 +100,7 @@ void main() {
storage: storage,
);
final discoverKey = GlobalKey<State<DiscoverScreen>>();
const sidebarOffset = SideNavigationRailState.expandedWidth;
addTearDown(() async {
activeProfileProvider.dispose();
@@ -121,7 +130,7 @@ void main() {
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
sideNavigationWidth: sidebarOffset,
child: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)),
),
),
@@ -135,21 +144,50 @@ void main() {
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,
expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + sidebarOffset, 0.001));
final railHeight = TvBrowseRailLayout.estimateHeight(
size: const Size(1280, 720),
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: settings.read(SettingsService.episodePosterMode),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (720 * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (720 - ((720 * 0.075).clamp(64.0 * scale, 120.0 * scale)) - (96 * scale))
.clamp(0.0, double.infinity)
.toDouble();
final expectedSpotlightBottom = desiredSpotlightBottom > maxSpotlightBottom
? maxSpotlightBottom
: desiredSpotlightBottom;
expect(spotlightBackground.contentBottom, closeTo(expectedSpotlightBottom, 0.001));
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,
);
expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
expect(backgroundPosition.left, -sidebarOffset);
expect(backgroundPosition.width, 1280);
tester.state<FocusableActionBarState>(find.byType(FocusableActionBar)).requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
(discoverKey.currentState! as FocusableTab).focusActiveTabIfReady();
(discoverKey.currentState! as TabVisibilityAware).onTabHidden();
await tester.pump();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
(discoverKey.currentState! as TabVisibilityAware).onTabShown();
(discoverKey.currentState! as FocusableTab).focusActiveTabIfReady();
await tester.pump();
await tester.pump();
+97 -3
View File
@@ -130,17 +130,109 @@ void main() {
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('Season 2'), findsOneWidget);
});
testWidgets('TV detail falls back to per-season episodes when descendant cache fails', (tester) async {
await SettingsService.getInstance();
final show = MediaItem(
id: 'show_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.show,
title: 'The Show',
serverId: 'server_1',
serverName: 'Server',
);
final season1 = MediaItem(
id: 'season_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
title: 'Season 1',
index: 1,
parentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final season2 = MediaItem(
id: 'season_2',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
title: 'Season 2',
index: 2,
parentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final episode1 = MediaItem(
id: 'episode_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'Episode 1',
index: 1,
parentId: season1.id,
parentIndex: season1.index,
grandparentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final episode2 = MediaItem(
id: 'episode_2',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'Episode 2',
index: 1,
parentId: season2.id,
parentIndex: season2.index,
grandparentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final client = _FakeMediaServerClient(
show: show,
childrenByParent: {
show.id: [season1, season2],
season1.id: [episode1],
season2.id: [episode2],
},
playableDescendantsError: Exception('descendant cache failed'),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(provider.dispose);
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)),
),
),
),
);
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('Season 2'), findsOneWidget);
});
}
class _FakeMediaServerClient implements MediaServerClient {
final MediaItem show;
final Map<String, List<MediaItem>> childrenByParent;
final Future<List<MediaItem>> pendingPlayableDescendants;
final Future<List<MediaItem>>? pendingPlayableDescendants;
final Object? playableDescendantsError;
_FakeMediaServerClient({
required this.show,
required this.childrenByParent,
required this.pendingPlayableDescendants,
this.pendingPlayableDescendants,
this.playableDescendantsError,
});
@override
@@ -169,7 +261,9 @@ class _FakeMediaServerClient implements MediaServerClient {
int? size,
AbortController? abort,
}) async {
final items = await pendingPlayableDescendants;
final error = playableDescendantsError;
if (error != null) throw error;
final items = await pendingPlayableDescendants!;
return LibraryPage(items: items, totalCount: items.length, offset: start ?? 0);
}
@@ -239,6 +239,64 @@ void main() {
expect(reports.last, isFalse);
});
testWidgets('Apple TV D-pad focus skips hidden downloads item', (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);
final sideNavKey = GlobalKey<SideNavigationRailState>();
NavigationTabId? selectedTab;
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(
key: sideNavKey,
selectedTab: NavigationTabId.discover,
isSidebarFocused: true,
alwaysExpanded: true,
onDestinationSelected: (tab) => selectedTab = tab,
onLibrarySelected: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
sideNavKey.currentState!.focusActiveItem();
await tester.pumpAndSettle();
// Home -> Libraries -> Search -> Settings. Downloads is hidden on Apple TV.
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.enter);
expect(selectedTab, NavigationTabId.settings);
});
testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async {
await SettingsService.getInstance();