diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index fa33cf031..99b141a59 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -51,6 +51,11 @@ class GridLayoutConstants { /// reserves ([posterAspectRatio] adds 0.3 to the 2:3 image's denominator). static const double squareGridCellAspectRatio = 2 / 2.3; + /// Inter-card gutter for square (music) grids on touch platforms. Cards + /// already carry their own 3px internal padding, so the net visual gutter + /// is ~14px horizontal. Automotive keeps its larger [crossAxisSpacing]. + static const double squareGridSpacing = 8.0; + static double get crossAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0; static double get mainAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0; diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index 063097999..5fc0ac36c 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -36,9 +36,22 @@ class MediaGridDelegate { return maxCrossAxisExtent; } - static double spacingFor({required BuildContext context, bool fullBleedImage = false}) { + /// Inter-cell gutter for the resolved shape. Square (music) grids get + /// [GridLayoutConstants.squareGridSpacing] so cards have breathing room; + /// every other shape keeps the platform default (0, or 24 on automotive). + /// Full-bleed TV grids use the scaled full-card gutter. + static double spacingFor({ + required BuildContext context, + bool useWideAspectRatio = false, + bool fullBleedImage = false, + CardShape? shape, + }) { if (PlatformDetector.isAutomotive()) return GridLayoutConstants.crossAxisSpacing; - if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing; + if (!fullBleedImage) { + return _resolveShape(shape, useWideAspectRatio) == CardShape.square + ? GridLayoutConstants.squareGridSpacing + : GridLayoutConstants.crossAxisSpacing; + } return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context)); } @@ -101,7 +114,12 @@ class MediaGridGeometry { bool fullBleedImage = false, CardShape? shape, }) { - final spacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullBleedImage); + final spacing = MediaGridDelegate.spacingFor( + context: context, + useWideAspectRatio: useWideAspectRatio, + fullBleedImage: fullBleedImage, + shape: shape, + ); final aspectRatio = MediaGridDelegate.aspectRatioFor( useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage, diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 035abda46..34308d768 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/services/multi_server_manager.dart'; 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/utils/layout_constants.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; @@ -182,6 +183,13 @@ void main() { .every((card) => card.cardShapeOverride == CardShape.square), isTrue, ); + + // Square grids keep their square gutter spacing even on TV full-card + // layout (which is disabled for square shapes). + final gridDelegate = + tester.widget(find.byType(SliverGrid)).gridDelegate as SliverGridDelegateWithMaxCrossAxisExtent; + expect(gridDelegate.crossAxisSpacing, GridLayoutConstants.squareGridSpacing); + expect(gridDelegate.mainAxisSpacing, GridLayoutConstants.squareGridSpacing); }); } diff --git a/test/widgets/media_card_square_test.dart b/test/widgets/media_card_square_test.dart index ffd006353..777d73ea2 100644 --- a/test/widgets/media_card_square_test.dart +++ b/test/widgets/media_card_square_test.dart @@ -60,6 +60,64 @@ void main() { expect(MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true), GridLayoutConstants.episodeGridCellAspectRatio); }); + testWidgets('square grid delegates use square gutters, others unchanged', (tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + SliverGridDelegateWithMaxCrossAxisExtent? square; + SliverGridDelegateWithMaxCrossAxisExtent? poster; + SliverGridDelegateWithMaxCrossAxisExtent? wide; + SliverGridDelegateWithMaxCrossAxisExtent? fullBleed; + await tester.pumpWidget( + _TestApp( + child: Builder( + builder: (context) { + square = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: 1280, + density: LibraryDensity.defaultValue, + shape: CardShape.square, + ).delegate; + poster = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: 1280, + density: LibraryDensity.defaultValue, + ).delegate; + wide = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: 1280, + density: LibraryDensity.defaultValue, + useWideAspectRatio: true, + ).delegate; + fullBleed = MediaGridGeometry.resolve( + context: context, + crossAxisExtent: 1280, + density: LibraryDensity.defaultValue, + fullBleedImage: true, + ).delegate; + return const SizedBox.shrink(); + }, + ), + ), + ); + + // Square (music) grids get breathing room. + expect(square!.crossAxisSpacing, GridLayoutConstants.squareGridSpacing); + expect(square!.mainAxisSpacing, GridLayoutConstants.squareGridSpacing); + // Poster and wide grids keep the platform default (0 off-automotive). + expect(poster!.crossAxisSpacing, GridLayoutConstants.crossAxisSpacing); + expect(poster!.mainAxisSpacing, GridLayoutConstants.crossAxisSpacing); + expect(wide!.crossAxisSpacing, GridLayoutConstants.crossAxisSpacing); + expect(wide!.mainAxisSpacing, GridLayoutConstants.crossAxisSpacing); + // Full-bleed TV gutters are unchanged. + expect(fullBleed!.crossAxisSpacing, GridLayoutConstants.fullCardGridSpacingForScale(0.85)); + expect(fullBleed!.mainAxisSpacing, fullBleed!.crossAxisSpacing); + }); + test('list layout sizes square cards 1:1', () { final base = MediaCardListLayout.basePosterWidth(LibraryDensity.defaultValue); expect(MediaCardListLayout.posterWidth(density: LibraryDensity.defaultValue, shape: CardShape.square), base);