import 'dart:math' as math; import 'package:flutter/material.dart'; import '../media/media_item.dart' show CardShape; import '../services/settings_service.dart'; import '../utils/grid_size_calculator.dart'; import '../utils/layout_constants.dart'; import '../utils/platform_detector.dart'; /// Shared grid metric helpers for media item grids — spacing, aspect ratio, /// and max cross-axis extent. [MediaGridGeometry.resolve] is the single place /// that composes them into a grid delegate. class MediaGridDelegate { /// Resolves the shape from the optional [shape] parameter, falling back to /// the legacy wide-vs-poster bool so existing call sites are byte-identical. static CardShape _resolveShape(CardShape? shape, bool useWideAspectRatio) => shape ?? (useWideAspectRatio ? CardShape.wide : CardShape.poster); /// Resolves the max cross-axis extent for [MediaGridGeometry.resolve], /// including the 1.8x widening for 16:9 episode thumbnails. Square cells /// keep the poster extent so column counts match the poster grid. /// /// This is the single widening scheme: wide cells widen the max extent /// BEFORE the integral column packing. Nothing multiplies the resolved cell /// afterwards — horizontal rows adopt the packed cell via [wideCellWidth] /// so a hub row and a grid of the same items match at equal width. static double _maxCrossAxisExtentFor({ required BuildContext context, required int density, required bool useWideAspectRatio, CardShape? shape, }) { var maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); // For wide aspect ratio (16:9), increase max extent so items are larger // and there are fewer per row (roughly 1.8x wider to maintain similar visual area) if (_resolveShape(shape, useWideAspectRatio) == CardShape.wide) { maxCrossAxisExtent *= 1.8; } return maxCrossAxisExtent; } /// The cell width the grid formula resolves for a wide (16:9) surface — /// the wide analogue of [GridSizeCalculator.getCellWidth]. Horizontal hub /// rows use this instead of scaling the poster cell so episode rows match /// the episode grid behind their "see all" page (#2039, plan item 3). static double wideCellWidth(BuildContext context, double availableWidth, int density) { final maxCrossAxisExtent = _maxCrossAxisExtentFor(context: context, density: density, useWideAspectRatio: true); // applyGridSpacingSetting: false — hub rows adopt the packed cell but // render no gutter, so the user's grid-spacing setting must not repack // them (their layout stays identical across the setting). final spacing = spacingFor(context: context, useWideAspectRatio: true, applyGridSpacingSetting: false); final columnCount = GridSizeCalculator.getColumnCount( availableWidth, maxCrossAxisExtent, crossAxisSpacing: spacing, ); return GridSizeCalculator.getCellWidthForColumnCount(availableWidth, columnCount, crossAxisSpacing: spacing); } /// Inter-cell gutter for the resolved shape. Square (music) grids get at /// least [GridLayoutConstants.squareGridSpacing] so cards have breathing /// room; every other shape starts from the platform default (0, or 24 on /// automotive). Full-bleed TV grids use the scaled full-card gutter. /// /// On top of the non-automotive, non-full-bleed base, the user's /// [SettingsService.gridSpacing] setting widens the gutter (#2083). /// [applyGridSpacingSetting] opts a caller out when its layout must not /// shift with the setting (hub-row cell packing). static double spacingFor({ required BuildContext context, bool useWideAspectRatio = false, bool fullBleedImage = false, CardShape? shape, bool applyGridSpacingSetting = true, }) { if (PlatformDetector.isAutomotive()) return GridLayoutConstants.crossAxisSpacing; if (fullBleedImage) return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context)); final base = _resolveShape(shape, useWideAspectRatio) == CardShape.square ? GridLayoutConstants.squareGridSpacing : GridLayoutConstants.crossAxisSpacing; if (!applyGridSpacingSetting) return base; return math.max(base, SettingsService.instance.read(SettingsService.gridSpacing).gridGap); } static double aspectRatioFor({bool useWideAspectRatio = false, bool fullBleedImage = false, CardShape? shape}) { final resolved = _resolveShape(shape, useWideAspectRatio); if (fullBleedImage) { return switch (resolved) { CardShape.wide => GridLayoutConstants.episodeThumbnailAspectRatio, CardShape.square => GridLayoutConstants.squareAspectRatio, CardShape.poster => GridLayoutConstants.fullCardPosterAspectRatio, }; } return switch (resolved) { CardShape.wide => GridLayoutConstants.episodeGridCellAspectRatio, CardShape.square => GridLayoutConstants.squareGridCellAspectRatio, CardShape.poster => GridLayoutConstants.posterAspectRatio, }; } } /// The grid layout a media grid will render for a given cross-axis extent: /// column count, cell size, spacing, and the matching delegate. /// /// Use with `SliverCrossAxisLayoutBuilder` so this is resolved once per /// width/settings change — never per scroll tick. [columnCount] follows the /// same formula [SliverGridDelegateWithMaxCrossAxisExtent] uses at layout /// time (see [GridSizeCalculator.getColumnCount], issue #1288), so d-pad row /// math and the rendered grid always agree. class MediaGridGeometry { final int columnCount; final double itemWidth; final double itemHeight; final double spacing; final SliverGridDelegateWithMaxCrossAxisExtent delegate; const MediaGridGeometry._({ required this.columnCount, required this.itemWidth, required this.itemHeight, required this.spacing, required this.delegate, }); /// Resolves the geometry for a grid laid out in [crossAxisExtent] (the /// sliver's width AFTER any wrapping [SliverPadding]). /// /// [crossAxisExtentForColumnCount], when non-null, computes the column /// count from that width instead, and pins the delegate's cell width to the /// resulting [itemWidth] — used by the library browse grid so the alpha /// jump bar's reservation doesn't repack the grid into fewer columns. static MediaGridGeometry resolve({ required BuildContext context, required double crossAxisExtent, required int density, double? crossAxisExtentForColumnCount, bool useWideAspectRatio = false, bool fullBleedImage = false, CardShape? shape, }) { final spacing = MediaGridDelegate.spacingFor( context: context, useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage, shape: shape, ); final aspectRatio = MediaGridDelegate.aspectRatioFor( useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage, shape: shape, ); final maxCrossAxisExtent = MediaGridDelegate._maxCrossAxisExtentFor( context: context, density: density, useWideAspectRatio: useWideAspectRatio, shape: shape, ); final columnCount = GridSizeCalculator.getColumnCount( crossAxisExtentForColumnCount ?? crossAxisExtent, maxCrossAxisExtent, crossAxisSpacing: spacing, ); final itemWidth = GridSizeCalculator.getCellWidthForColumnCount( crossAxisExtent, columnCount, crossAxisSpacing: spacing, ); return MediaGridGeometry._( columnCount: columnCount, itemWidth: itemWidth, itemHeight: itemWidth / aspectRatio, spacing: spacing, delegate: SliverGridDelegateWithMaxCrossAxisExtent( // When the column count is pinned to a different basis width, the // delegate must pack exactly [columnCount] columns into the real // extent, so cap cells at the derived width instead. maxCrossAxisExtent: crossAxisExtentForColumnCount != null ? itemWidth : maxCrossAxisExtent, childAspectRatio: aspectRatio, crossAxisSpacing: spacing, mainAxisSpacing: spacing, ), ); } }