Files
plezy/lib/widgets/media_grid_delegate.dart
edde746 94565b4ee0 feat(library): make grid spacing between posters configurable
The library grid packs posters edge to edge. The grid delegate's cross-
and main-axis spacing were hardcoded to zero, so the only visible gap
came from each card's own 3px padding, and Library Density changed
poster size rather than the space between posters. The result reads
denser than Plex, Jellyfin, or any commercial client, both horizontally
between posters and vertically between a poster and its title.

Add a Grid Spacing setting - Tight, Normal, Spacious (0/6/12px) - below
Library Density. Tight is the default and reproduces the current layout
exactly, so no existing grid moves on update.

The gutter lands in MediaGridDelegate.spacingFor(), already the single
funnel for non-full-bleed grid spacing, so every grid surface picks it
up: library browse, collections, playlists, downloads, and the detail
screens. Square music grids keep their 8px floor. The pref is watched
once in MediaCardSliverLayout - the only MediaGridGeometry.resolve()
call site - so grids re-layout live instead of on restart.

Grid cells grow the poster-to-title gap to 2/4/6px as well; the
Expanded poster absorbs the delta, so the cell keeps its aspect ratio.
Fixed-height hub-row cards keep the historical 2px because their text
band cannot absorb more, and hub-row cell packing opts out of the
setting entirely so shelves stay byte-identical across it.

Full-bleed TV grids are untouched - they already carry an 8-18px
scale-derived gutter - and are opt-in behind tvFullCardLayout, so the
default TV library grid follows the setting like every other surface.

Verified on macOS across all three steps: gutters and title gap grow,
grids re-pack live, and the segmented control reflects and writes the
value.

close #1597
close #2083
2026-08-27 09:21:48 +02:00

189 lines
8.0 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 [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,
),
);
}
}