Files
plezy/lib/widgets/media_grid_delegate.dart
T
edde746 9a9b89082b fix(home): apply the grid spacing setting to hub rows
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
2026-09-02 22:38:11 +02:00

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,
),
);
}
}