Files
plezy/lib/widgets/media_grid_delegate.dart
T
edde746 21f48382bc fix(hubs): derive wide hub-row cards from the grid's widening scheme
A continue-watching episode card was 180dp on home but 232dp in the
grid behind "see all" at 480dp: hub rows widened the resolved poster
cell (x1.5) while grids widen the max extent (x1.8) before the integral
column packing, and the TV shelf multiplied the rail's tall card by the
same 1.5. Rows now adopt the grid's packed wide cell via
MediaGridDelegate.wideCellWidth, the TV shelf passes the hub's wide
flag to TvBrowseRailLayout.cardWidthFor, and the single widening
scheme is stated in MediaGridDelegate. Grids are untouched.
2026-08-20 14:49:33 +02:00

177 lines
7.2 KiB
Dart

import 'package:flutter/material.dart';
import '../media/media_item.dart' show CardShape;
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);
final spacing = spacingFor(context: context, useWideAspectRatio: true);
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
/// [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 _resolveShape(shape, useWideAspectRatio) == CardShape.square
? GridLayoutConstants.squareGridSpacing
: GridLayoutConstants.crossAxisSpacing;
}
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
}
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,
),
);
}
}