Grid Spacing (Settings > Appearance) only changed the library grid. The home, explore, detail and library-recommended rows were byte-identical across Tight/Normal/Spacious because #2083 deliberately opted hub-row cell packing out of the setting and hard-coded a 2px pad per card, and the TV home rail never read the setting at all. Treat a hub row as one row of the grid. MediaGridDelegate.cellWidth packs row cells with the same gutter MediaGridGeometry uses, so a row and the "see all" grid behind it render the same card at every setting. HubSection renders max(4, gap) between cards - Tight keeps its historical 4px so nothing moves on update, Normal/Spacious render the grid's 6/12px gutter - and feeds the same gap into its D-pad item extent and card memo epoch. TvBrowseRail threads GridSpacing through metricsForHub, maxActiveRailHeight and estimateHeight; non-full-card rails use the setting scaled with the rest of the rail metrics, full-card rails keep their own scale-derived gutter like full-bleed grids. The spotlight and detail rail height reservations pass the setting too so reserved heights track the narrower cards. Both surfaces watch the pref, so rows re-lay out live. close #2226
192 lines
7.7 KiB
Dart
192 lines
7.7 KiB
Dart
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 [cellWidth]
|
|
/// 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 [availableWidth] — the same
|
|
/// packing [MediaGridGeometry.resolve] performs, including the user's
|
|
/// grid-spacing gutter. Horizontal hub rows use this so a row and the grid
|
|
/// behind its "see all" page render the same card at equal width (#2039),
|
|
/// at every grid-spacing setting (#2226).
|
|
static double cellWidth({
|
|
required BuildContext context,
|
|
required double availableWidth,
|
|
required int density,
|
|
bool useWideAspectRatio = false,
|
|
}) {
|
|
final maxCrossAxisExtent = _maxCrossAxisExtentFor(
|
|
context: context,
|
|
density: density,
|
|
useWideAspectRatio: useWideAspectRatio,
|
|
);
|
|
final spacing = spacingFor(context: context, useWideAspectRatio: useWideAspectRatio);
|
|
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).
|
|
static double spacingFor({
|
|
required BuildContext context,
|
|
bool useWideAspectRatio = false,
|
|
bool fullBleedImage = false,
|
|
CardShape? shape,
|
|
}) {
|
|
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;
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|