diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 8227c903..df59c57a 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -137,7 +137,15 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState widget.library.kind.isMusic; + bool get _usesSquareCards { + final loaded = loadedItems.values; + return loaded.isNotEmpty && loaded.every(_isMusicCollection); + } + + // Plex collection rows are library-scoped, so their container kind is a + // safe fallback. Jellyfin BoxSets are server-wide and must opt in per item. + bool _isMusicCollection(MediaItem item) => + item.kind.isMusic || (item is PlexMediaItem && widget.library.kind.isMusic); Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { final shape = _usesSquareCards ? CardShape.square : null; diff --git a/test/screens/collection_detail_screen_test.dart b/test/screens/collection_detail_screen_test.dart index a4bd832f..ff6aae5e 100644 --- a/test/screens/collection_detail_screen_test.dart +++ b/test/screens/collection_detail_screen_test.dart @@ -22,6 +22,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; import 'package:provider/provider.dart'; @@ -37,8 +38,11 @@ void main() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(false); }); + tearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + testWidgets('music collection contents use square grid geometry and cards', (tester) async { final album = testMediaItem( id: 'album_1', @@ -49,6 +53,8 @@ void main() { serverName: 'Server', ); final harness = await _createHarness([album]); + TvDetectionService.debugSetAppleTVOverride(true); + await SettingsService.instance.write(SettingsService.tvFullCardLayout, true); await tester.pumpWidget( harness.wrap(SizedBox(width: 1280, height: 720, child: CollectionDetailScreen(collection: _collection))), diff --git a/test/screens/libraries/library_collections_tab_test.dart b/test/screens/libraries/library_collections_tab_test.dart index f3769a25..20f53cd1 100644 --- a/test/screens/libraries/library_collections_tab_test.dart +++ b/test/screens/libraries/library_collections_tab_test.dart @@ -12,24 +12,30 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; +import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_collections_tab.dart'; import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/backend_client_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('collection-server'); +final _jellyfinServerId = ServerId('jellyfin-collection-server'); final _musicLibrary = MediaLibrary( id: 'music', backend: MediaBackend.plex, @@ -37,6 +43,13 @@ final _musicLibrary = MediaLibrary( kind: MediaKind.artist, serverId: _serverId, ); +final _jellyfinMusicLibrary = MediaLibrary( + id: 'music-library', + backend: MediaBackend.jellyfin, + title: 'Music', + kind: MediaKind.artist, + serverId: _jellyfinServerId, +); void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -45,63 +58,91 @@ void main() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); CardInflationBudget.reset(); + TvDetectionService.debugSetAppleTVOverride(false); await SettingsService.getInstance(); }); + tearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + testWidgets('music library collections use square grid geometry and square cards', (tester) async { - final harness = _CollectionHarness(); + final harness = _CollectionHarness.plex(); addTearDown(harness.dispose); + TvDetectionService.debugSetAppleTVOverride(true); + await SettingsService.instance.write(SettingsService.tvFullCardLayout, true); - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryCollectionsTab(library: _musicLibrary, suppressAutoFocus: true, onBack: () {}), - ), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); + await _pumpTab(tester, harness: harness, library: _musicLibrary); final layout = tester.widget(find.byType(MediaCardSliverLayout)); expect(layout.shape, CardShape.square); expect(layout.fullBleedImage, isFalse); expect(tester.widget(find.byType(FocusableMediaCard)).cardShapeOverride, CardShape.square); }); + + testWidgets('Jellyfin video collections keep poster geometry when opened from a music library', (tester) async { + final harness = _CollectionHarness.jellyfin(); + addTearDown(harness.dispose); + TvDetectionService.debugSetAppleTVOverride(true); + await SettingsService.instance.write(SettingsService.tvFullCardLayout, true); + + await _pumpTab(tester, harness: harness, library: _jellyfinMusicLibrary); + + final layout = tester.widget(find.byType(MediaCardSliverLayout)); + expect(layout.shape, isNull); + expect(layout.fullBleedImage, isTrue); + expect(tester.widget(find.byType(FocusableMediaCard)).cardShapeOverride, isNull); + }); +} + +Future _pumpTab(WidgetTester tester, {required _CollectionHarness harness, required MediaLibrary library}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 600); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: harness.provider, + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: 0, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), + ), + ], + body: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); } class _CollectionHarness { - late final AppDatabase database; + final AppDatabase database; late final MultiServerManager manager; late final MultiServerProvider provider; - _CollectionHarness() { - database = AppDatabase.forTesting(NativeDatabase.memory()); + _CollectionHarness._({required this.database, required MediaServerClient client}) { + manager = MultiServerManager()..debugRegisterClientForTesting(client); + provider = MultiServerProvider(manager, DataAggregationService(manager)); + } + + factory _CollectionHarness.plex() { + final database = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(database); final client = PlexClient.forTesting( config: PlexConfig( @@ -131,8 +172,48 @@ class _CollectionHarness { ); }), ); - manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + return _CollectionHarness._(database: database, client: client); + } + + factory _CollectionHarness.jellyfin() { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(database); + final client = JellyfinClient.forTesting( + connection: testJellyfinConnection(machineId: _jellyfinServerId), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Views') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'boxsets-root', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items') { + return http.Response( + jsonEncode({ + 'TotalRecordCount': 1, + 'Items': [ + { + 'Id': 'video-collection-1', + 'Name': 'Movie Collection', + 'Type': 'BoxSet', + 'MediaType': 'Video', + 'ParentId': 'boxsets-root', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + return _CollectionHarness._(database: database, client: client); } Future dispose() async { diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 70f12788..a9632b77 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -24,6 +24,7 @@ import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; @@ -54,9 +55,12 @@ void main() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); CardInflationBudget.reset(); + TvDetectionService.debugSetAppleTVOverride(false); await SettingsService.getInstance(); }); + tearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + testWidgets('grid lazily builds playlist cards and preserves focus navigation', (tester) async { final harness = _PlaylistHarness(); addTearDown(harness.dispose); @@ -154,6 +158,8 @@ void main() { final harness = _PlaylistHarness(playlistType: 'audio'); addTearDown(harness.dispose); addTearDown(harness.rebuild.dispose); + TvDetectionService.debugSetAppleTVOverride(true); + await SettingsService.instance.write(SettingsService.tvFullCardLayout, true); await _pumpTab(tester, harness: harness, library: _musicLibrary, onBack: () {}, onSidebar: () {}); diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index a143eebc..922f83a3 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -131,6 +131,8 @@ void main() { serverName: 'Server', ), ]); + TvDetectionService.debugSetAppleTVOverride(true); + await SettingsService.instance.write(SettingsService.tvFullCardLayout, true); await tester.pumpWidget( harness.wrap(