@@ -4,7 +4,7 @@ import '../services/settings_service.dart';
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
SettingsService? _settingsService;
|
||||
LibraryDensity _libraryDensity = LibraryDensity.normal;
|
||||
int _libraryDensity = LibraryDensity.defaultValue;
|
||||
ViewMode _viewMode = ViewMode.grid;
|
||||
EpisodePosterMode _episodePosterMode = EpisodePosterMode.seriesPoster;
|
||||
bool _showHeroSection = true;
|
||||
@@ -49,7 +49,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
/// Whether the provider has completed initialization
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
LibraryDensity get libraryDensity => _libraryDensity;
|
||||
int get libraryDensity => _libraryDensity;
|
||||
|
||||
ViewMode get viewMode => _viewMode;
|
||||
|
||||
@@ -86,8 +86,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setLibraryDensity(LibraryDensity density) => _updateSetting(
|
||||
current: _libraryDensity, value: density,
|
||||
Future<void> setLibraryDensity(int density) => _updateSetting(
|
||||
current: _libraryDensity, value: density.clamp(LibraryDensity.min, LibraryDensity.max),
|
||||
setLocal: (v) => _libraryDensity = v,
|
||||
persist: _settingsService!.setLibraryDensity,
|
||||
);
|
||||
@@ -152,17 +152,6 @@ class SettingsProvider extends ChangeNotifier {
|
||||
persist: _settingsService!.setLiveTvDefaultFavorites,
|
||||
);
|
||||
|
||||
String get libraryDensityDisplayName {
|
||||
switch (_libraryDensity) {
|
||||
case LibraryDensity.compact:
|
||||
return 'Compact';
|
||||
case LibraryDensity.normal:
|
||||
return 'Normal';
|
||||
case LibraryDensity.comfortable:
|
||||
return 'Comfortable';
|
||||
}
|
||||
}
|
||||
|
||||
String get episodePosterModeDisplayName {
|
||||
switch (_episodePosterMode) {
|
||||
case EpisodePosterMode.seriesPoster:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../services/settings_service.dart' show ViewMode, LibraryDensity;
|
||||
import '../../services/settings_service.dart' show ViewMode;
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../utils/layout_constants.dart';
|
||||
import '../main_screen.dart';
|
||||
@@ -84,7 +84,7 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Builds either a list or grid view based on the view mode
|
||||
Widget _buildItemsView(BuildContext context, ViewMode viewMode, LibraryDensity density) {
|
||||
Widget _buildItemsView(BuildContext context, ViewMode viewMode, int density) {
|
||||
final basePadding = padding ?? GridLayoutConstants.gridPadding;
|
||||
// Add extra top padding for focus decoration of first row items
|
||||
final effectivePadding = basePadding.copyWith(top: basePadding.top + _focusDecorationPadding);
|
||||
|
||||
@@ -14,10 +14,9 @@ import '../../../models/livetv_hub_result.dart';
|
||||
import '../../../models/plex_metadata.dart';
|
||||
import '../../../providers/multi_server_provider.dart';
|
||||
import '../../../providers/settings_provider.dart';
|
||||
import '../../../services/settings_service.dart' show LibraryDensity;
|
||||
import '../../../utils/grid_size_calculator.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/live_tv_player_navigation.dart';
|
||||
import '../../../utils/plex_image_helper.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
@@ -470,12 +469,6 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
Widget build(BuildContext context) {
|
||||
final hasFocus = _hubFocusNode.hasFocus;
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final densityScale = switch (settings.libraryDensity) {
|
||||
LibraryDensity.compact => 0.8,
|
||||
LibraryDensity.normal => 1.0,
|
||||
LibraryDensity.comfortable => 1.15,
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -507,20 +500,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenWidth = constraints.maxWidth;
|
||||
double baseWidth;
|
||||
if (ScreenBreakpoints.isLargeDesktop(screenWidth)) {
|
||||
baseWidth = 220.0;
|
||||
} else if (ScreenBreakpoints.isDesktop(screenWidth)) {
|
||||
baseWidth = 200.0;
|
||||
} else if (ScreenBreakpoints.isWideTablet(screenWidth)) {
|
||||
baseWidth = 190.0;
|
||||
} else {
|
||||
baseWidth = 160.0;
|
||||
}
|
||||
final baseCardWidth = baseWidth * densityScale;
|
||||
|
||||
final cardWidth = baseCardWidth;
|
||||
final cardWidth = GridSizeCalculator.getCellWidth(constraints.maxWidth, context, settings.libraryDensity);
|
||||
final posterWidth = cardWidth - 16;
|
||||
final posterHeight = posterWidth * 1.5; // 2:3 aspect
|
||||
final containerHeight = posterHeight + 66;
|
||||
|
||||
@@ -31,6 +31,8 @@ import '../models/download_models.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/offline_watch_provider.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
@@ -1512,13 +1514,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return KeyEventResult.handled; // consume to prevent unwanted traversal
|
||||
}
|
||||
|
||||
/// Get the responsive card width used by seasons/extras/cast rows
|
||||
/// Get the responsive card width used by seasons/extras/cast rows.
|
||||
/// Uses the shared grid size calculator for consistency with library grids.
|
||||
double _getResponsiveCardWidth() {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth >= 1400) return 220.0;
|
||||
if (screenWidth >= 900) return 200.0;
|
||||
if (screenWidth >= 700) return 190.0;
|
||||
return 160.0;
|
||||
final density = context.read<SettingsProvider>().libraryDensity;
|
||||
final availableWidth = MediaQuery.of(context).size.width;
|
||||
return GridSizeCalculator.getCellWidth(availableWidth, context, density);
|
||||
}
|
||||
|
||||
/// Handle key events for the overview section
|
||||
@@ -1759,7 +1760,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (key.isLeftKey) {
|
||||
if (_focusedCastIndex > 0) {
|
||||
setState(() => _focusedCastIndex--);
|
||||
scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: 120.0 + 8 + 4, leadingPadding: 0);
|
||||
scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: _getResponsiveCardWidth() + 8 + 4, leadingPadding: 0);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -1768,7 +1769,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (key.isRightKey) {
|
||||
if (_focusedCastIndex < roleCount - 1) {
|
||||
setState(() => _focusedCastIndex++);
|
||||
scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: 120.0 + 8 + 4, leadingPadding: 0);
|
||||
scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: _getResponsiveCardWidth() + 8 + 4, leadingPadding: 0);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -2648,10 +2649,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// Build the cast section with locked focus pattern for D-pad navigation
|
||||
/// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2))
|
||||
Widget _buildCastSection(PlexMetadata metadata) {
|
||||
const cardWidth = 120.0;
|
||||
final cardWidth = _getResponsiveCardWidth();
|
||||
const innerPadding = 4.0;
|
||||
final imageSize = cardWidth;
|
||||
// image + inner padding + text area + outer list padding + focus scale headroom
|
||||
const containerHeight = 120.0 + innerPadding * 2 + 66 + 16;
|
||||
final containerHeight = imageSize + innerPadding * 2 + 66 + 16;
|
||||
|
||||
final hasFocus = _castFocusNode.hasFocus;
|
||||
|
||||
@@ -2690,8 +2692,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
child: PlexOptimizedImage(
|
||||
client: _getClientForMetadata(context),
|
||||
imagePath: actor.thumb,
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
fit: BoxFit.cover,
|
||||
imageType: ImageType.avatar,
|
||||
fallbackIcon: Symbols.person_rounded,
|
||||
|
||||
@@ -137,16 +137,30 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
Widget _buildDensitySelector() {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SegmentedSetting<settings.LibraryDensity>(
|
||||
icon: Symbols.grid_view_rounded,
|
||||
title: t.settings.libraryDensity,
|
||||
segments: [
|
||||
ButtonSegment(value: settings.LibraryDensity.compact, label: Text(t.settings.compact)),
|
||||
ButtonSegment(value: settings.LibraryDensity.normal, label: Text(t.settings.normal)),
|
||||
ButtonSegment(value: settings.LibraryDensity.comfortable, label: Text(t.settings.comfortable)),
|
||||
],
|
||||
selected: settingsProvider.libraryDensity,
|
||||
onChanged: (value) => settingsProvider.setLibraryDensity(value),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const AppIcon(Symbols.grid_view_rounded, fill: 1),
|
||||
const SizedBox(width: 16),
|
||||
Text(t.settings.compact, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: settingsProvider.libraryDensity.toDouble(),
|
||||
min: 1,
|
||||
max: 5,
|
||||
divisions: 4,
|
||||
onChanged: (value) => settingsProvider.setLibraryDensity(value.round()),
|
||||
),
|
||||
),
|
||||
Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -178,8 +192,7 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
segments: [
|
||||
ButtonSegment(value: settings.EpisodePosterMode.seriesPoster, label: Text(t.settings.seriesPoster)),
|
||||
ButtonSegment(value: settings.EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)),
|
||||
ButtonSegment(
|
||||
value: settings.EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)),
|
||||
ButtonSegment(value: settings.EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)),
|
||||
],
|
||||
selected: settingsProvider.episodePosterMode,
|
||||
onChanged: (value) => settingsProvider.setEpisodePosterMode(value),
|
||||
|
||||
@@ -187,7 +187,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
Widget _buildAppearanceTile() {
|
||||
return Consumer2<ThemeProvider, SettingsProvider>(
|
||||
builder: (context, themeProvider, settingsProvider, child) {
|
||||
final summary = '${themeProvider.themeModeDisplayName} · ${settingsProvider.libraryDensityDisplayName}';
|
||||
final summary = '${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} ${settingsProvider.libraryDensity}';
|
||||
return ListTile(
|
||||
focusNode: _focusTracker.get(_kAppearance),
|
||||
leading: const AppIcon(Symbols.palette_rounded, fill: 1),
|
||||
|
||||
@@ -13,7 +13,16 @@ import '../utils/platform_detector.dart';
|
||||
|
||||
enum ThemeMode { system, light, dark, oled }
|
||||
|
||||
enum LibraryDensity { compact, normal, comfortable }
|
||||
/// Library density is now an int 1–5 (1 = most compact, 5 = most comfortable).
|
||||
/// Default is 3.
|
||||
class LibraryDensity {
|
||||
static const int min = 1;
|
||||
static const int max = 5;
|
||||
static const int defaultValue = 3;
|
||||
|
||||
/// Returns a 0.0–1.0 factor for interpolation (0 = most compact, 1 = most comfortable).
|
||||
static double factor(int density) => (density.clamp(min, max) - min) / (max - min);
|
||||
}
|
||||
|
||||
enum ViewMode { grid, list }
|
||||
|
||||
@@ -209,13 +218,24 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
return prefs.getString(_keyPreferredAudioCodec) ?? 'auto';
|
||||
}
|
||||
|
||||
// Library Density
|
||||
Future<void> setLibraryDensity(LibraryDensity density) async {
|
||||
await prefs.setString(_keyLibraryDensity, density.name);
|
||||
// Library Density (int 1–5)
|
||||
Future<void> setLibraryDensity(int density) async {
|
||||
await prefs.setInt(_keyLibraryDensity, density.clamp(LibraryDensity.min, LibraryDensity.max));
|
||||
}
|
||||
|
||||
LibraryDensity getLibraryDensity() {
|
||||
return _getEnumValue(_keyLibraryDensity, LibraryDensity.values, LibraryDensity.normal);
|
||||
int getLibraryDensity() {
|
||||
// New int format
|
||||
final intVal = prefs.getInt(_keyLibraryDensity);
|
||||
if (intVal != null) return intVal.clamp(LibraryDensity.min, LibraryDensity.max);
|
||||
// Migrate from old enum string format
|
||||
final strVal = prefs.getString(_keyLibraryDensity);
|
||||
final result = switch (strVal) {
|
||||
'compact' => 2,
|
||||
'comfortable' => 4,
|
||||
_ => LibraryDensity.defaultValue,
|
||||
};
|
||||
prefs.setInt(_keyLibraryDensity, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// View Mode
|
||||
@@ -1374,7 +1394,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
'enableHardwareDecoding': getEnableHardwareDecoding(),
|
||||
'preferredVideoCodec': getPreferredVideoCodec(),
|
||||
'preferredAudioCodec': getPreferredAudioCodec(),
|
||||
'libraryDensity': getLibraryDensity().name,
|
||||
'libraryDensity': getLibraryDensity(),
|
||||
'viewMode': getViewMode().name,
|
||||
'episodePosterMode': getEpisodePosterMode().name,
|
||||
'seekTimeSmall': getSeekTimeSmall(),
|
||||
|
||||
@@ -118,6 +118,14 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle),
|
||||
filledButtonTheme: FilledButtonThemeData(style: buttonStyle),
|
||||
sliderTheme: SliderThemeData(
|
||||
trackHeight: 16,
|
||||
trackGap: 6,
|
||||
thumbSize: const WidgetStatePropertyAll(Size(4, 20)),
|
||||
thumbShape: const HandleThumbShape(),
|
||||
trackShape: const GappedSliderTrackShape(),
|
||||
tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 2),
|
||||
),
|
||||
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
|
||||
listTileTheme: ListTileThemeData(
|
||||
dense: true,
|
||||
|
||||
@@ -5,97 +5,47 @@ import 'platform_detector.dart';
|
||||
|
||||
/// Utility class for calculating consistent grid sizes across the app
|
||||
class GridSizeCalculator {
|
||||
/// Calculates the maximum cross-axis extent for grid items based on screen size and density
|
||||
static double getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isTV = PlatformDetector.isTV();
|
||||
final isDesktopOrLarger = ScreenBreakpoints.isDesktopOrLarger(screenWidth);
|
||||
final isTablet = ScreenBreakpoints.isTablet(screenWidth);
|
||||
static double _lerp(double min, double max, double t) => min + (max - min) * t;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
if (isTV) return GridLayoutConstants.comfortableTV;
|
||||
if (isDesktopOrLarger) return GridLayoutConstants.comfortableDesktop;
|
||||
if (isTablet) return GridLayoutConstants.comfortableTablet;
|
||||
return GridLayoutConstants.comfortableMobile;
|
||||
case LibraryDensity.compact:
|
||||
if (isTV) return GridLayoutConstants.compactTV;
|
||||
if (isDesktopOrLarger) return GridLayoutConstants.compactDesktop;
|
||||
if (isTablet) return GridLayoutConstants.compactTablet;
|
||||
return GridLayoutConstants.compactMobile;
|
||||
case LibraryDensity.normal:
|
||||
if (isTV) return GridLayoutConstants.normalTV;
|
||||
if (isDesktopOrLarger) return GridLayoutConstants.normalDesktop;
|
||||
if (isTablet) return GridLayoutConstants.normalTablet;
|
||||
return GridLayoutConstants.normalMobile;
|
||||
}
|
||||
/// Calculates the maximum cross-axis extent for grid items based on screen size and density.
|
||||
/// [density] is an int 1–5 (1 = most compact, 5 = most comfortable).
|
||||
static double getMaxCrossAxisExtent(BuildContext context, int density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final f = LibraryDensity.factor(density);
|
||||
|
||||
if (PlatformDetector.isTV()) return _lerp(120, 220, f);
|
||||
if (ScreenBreakpoints.isDesktopOrLarger(screenWidth)) return _lerp(140, 280, f);
|
||||
if (ScreenBreakpoints.isTablet(screenWidth)) return _lerp(120, 230, f);
|
||||
return _lerp(100, 200, f);
|
||||
}
|
||||
|
||||
/// Calculates the max cross-axis extent accounting for outer padding.
|
||||
///
|
||||
/// Uses responsive strategies:
|
||||
/// - Wide screens (>=900px): Divisor-based calculation with max item width
|
||||
/// - Medium screens (600-899px): Fixed item count (4-6 items based on density)
|
||||
/// - Small screens (<600px): Fixed item count (2-4 items based on density)
|
||||
/// [density] is an int 1–5.
|
||||
static double getMaxCrossAxisExtentWithPadding(
|
||||
BuildContext context,
|
||||
LibraryDensity density,
|
||||
int density,
|
||||
double horizontalPadding,
|
||||
) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final availableWidth = screenWidth - horizontalPadding;
|
||||
final f = LibraryDensity.factor(density);
|
||||
|
||||
// TV-specific sizing for 10ft viewing distance
|
||||
if (PlatformDetector.isTV()) {
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 7.0;
|
||||
maxItemWidth = 220;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 9.0;
|
||||
maxItemWidth = 190;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 11.0;
|
||||
maxItemWidth = 160;
|
||||
}
|
||||
final divisor = _lerp(12, 6, f);
|
||||
final maxItemWidth = _lerp(140, 240, f);
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
}
|
||||
|
||||
if (ScreenBreakpoints.isWideTabletOrLarger(screenWidth)) {
|
||||
// Wide screens (desktop/large tablet landscape): Responsive division
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 5.5;
|
||||
maxItemWidth = 260;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 230;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 9.0;
|
||||
maxItemWidth = 160;
|
||||
}
|
||||
|
||||
final divisor = _lerp(10, 5, f);
|
||||
final maxItemWidth = _lerp(140, 280, f);
|
||||
return (availableWidth / divisor).clamp(0, maxItemWidth);
|
||||
} else if (ScreenBreakpoints.isTablet(screenWidth)) {
|
||||
// Medium screens (tablets): Fixed 3-4-6 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 3,
|
||||
LibraryDensity.normal => 4,
|
||||
LibraryDensity.compact => 6,
|
||||
};
|
||||
final targetItemCount = _lerp(6, 3, f);
|
||||
return availableWidth / targetItemCount;
|
||||
} else {
|
||||
// Small screens (phones): Fixed 2-3-4 items
|
||||
int targetItemCount = switch (density) {
|
||||
LibraryDensity.comfortable => 2,
|
||||
LibraryDensity.normal => 3,
|
||||
LibraryDensity.compact => 4,
|
||||
};
|
||||
final targetItemCount = _lerp(5, 2, f);
|
||||
return availableWidth / targetItemCount;
|
||||
}
|
||||
}
|
||||
@@ -113,6 +63,15 @@ class GridSizeCalculator {
|
||||
return ((crossAxisExtent + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100);
|
||||
}
|
||||
|
||||
/// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce
|
||||
/// for the given [availableWidth]. This matches SliverGridDelegateWithMaxCrossAxisExtent's
|
||||
/// internal calculation, so horizontal scroll lists can use the same width as grids.
|
||||
static double getCellWidth(double availableWidth, BuildContext context, int density) {
|
||||
final maxExtent = getMaxCrossAxisExtent(context, density);
|
||||
final columns = getColumnCount(availableWidth, maxExtent);
|
||||
return availableWidth / columns;
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of a grid with given column count.
|
||||
static bool isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
|
||||
@@ -44,26 +44,6 @@ class ScreenBreakpoints {
|
||||
|
||||
/// Grid layout constants
|
||||
class GridLayoutConstants {
|
||||
/// Maximum cross-axis extent for grid items in comfortable density mode
|
||||
static const double comfortableDesktop = 250;
|
||||
static const double comfortableTablet = 210;
|
||||
static const double comfortableMobile = 180;
|
||||
|
||||
/// Maximum cross-axis extent for grid items in compact density mode
|
||||
static const double compactDesktop = 160;
|
||||
static const double compactTablet = 140;
|
||||
static const double compactMobile = 120;
|
||||
|
||||
/// Maximum cross-axis extent for grid items in normal density mode
|
||||
static const double normalDesktop = 220;
|
||||
static const double normalTablet = 185;
|
||||
static const double normalMobile = 155;
|
||||
|
||||
/// Maximum cross-axis extent for grid items on TV (10ft viewing distance)
|
||||
static const double comfortableTV = 200;
|
||||
static const double normalTV = 170;
|
||||
static const double compactTV = 140;
|
||||
|
||||
/// Default aspect ratio for media card grid cells (poster + text)
|
||||
static const double posterAspectRatio = 2 / 3.3;
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart' show EpisodePosterMode, LibraryDensity;
|
||||
import '../services/settings_service.dart' show EpisodePosterMode;
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../focus/locked_hub_controller.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../screens/hub_detail_screen.dart';
|
||||
@@ -378,28 +378,10 @@ class HubSectionState extends State<HubSection> {
|
||||
Focus(
|
||||
focusNode: _hubFocusNode,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Use MediaQuery instead of LayoutBuilder to save 1 element level.
|
||||
// HubSection is always full-width inside CustomScrollView.
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final densityScale = switch (settings.libraryDensity) {
|
||||
LibraryDensity.compact => 0.8,
|
||||
LibraryDensity.normal => 1.0,
|
||||
LibraryDensity.comfortable => 1.15,
|
||||
};
|
||||
double baseWidth;
|
||||
if (ScreenBreakpoints.isLargeDesktop(screenWidth)) {
|
||||
baseWidth = 220.0;
|
||||
} else if (ScreenBreakpoints.isDesktop(screenWidth)) {
|
||||
baseWidth = 200.0;
|
||||
} else if (ScreenBreakpoints.isWideTablet(screenWidth)) {
|
||||
baseWidth = 190.0;
|
||||
} else {
|
||||
baseWidth = 160.0;
|
||||
}
|
||||
final baseCardWidth = baseWidth * densityScale;
|
||||
final baseCardWidth = GridSizeCalculator.getCellWidth(constraints.maxWidth, context, settings.libraryDensity);
|
||||
|
||||
// Get episode poster mode setting
|
||||
final episodePosterMode = settings.episodePosterMode;
|
||||
|
||||
@@ -194,7 +194,7 @@ class MediaCardState extends State<MediaCard> {
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
density: context.select<SettingsProvider, LibraryDensity>((s) => s.libraryDensity),
|
||||
density: context.select<SettingsProvider, int>((s) => s.libraryDensity),
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
showServerName: widget.showServerName,
|
||||
@@ -317,7 +317,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
final void Function(TapDownDetails)? onTapDown;
|
||||
final VoidCallback? onSecondaryTap;
|
||||
final void Function(TapDownDetails)? onSecondaryTapDown;
|
||||
final LibraryDensity density;
|
||||
final int density;
|
||||
final bool isOffline;
|
||||
final String? localPosterPath;
|
||||
final bool showServerName;
|
||||
@@ -337,14 +337,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
});
|
||||
|
||||
double _basePosterWidth() {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 80;
|
||||
case LibraryDensity.normal:
|
||||
return 100;
|
||||
case LibraryDensity.comfortable:
|
||||
return 120;
|
||||
}
|
||||
return 70 + LibraryDensity.factor(density) * 50; // 70–120
|
||||
}
|
||||
|
||||
double _posterWidth(BuildContext context) {
|
||||
@@ -372,54 +365,18 @@ class _MediaCardList extends StatelessWidget {
|
||||
return base * 1.5; // Default 2:3 aspect ratio
|
||||
}
|
||||
|
||||
double get _titleFontSize {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 14;
|
||||
case LibraryDensity.normal:
|
||||
return 15;
|
||||
case LibraryDensity.comfortable:
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
double get _titleFontSize => 13 + LibraryDensity.factor(density) * 3; // 13–16
|
||||
|
||||
double get _metadataFontSize {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 11;
|
||||
case LibraryDensity.normal:
|
||||
return 12;
|
||||
case LibraryDensity.comfortable:
|
||||
return 13;
|
||||
}
|
||||
}
|
||||
double get _metadataFontSize => 10 + LibraryDensity.factor(density) * 3; // 10–13
|
||||
|
||||
double get _subtitleFontSize {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 12;
|
||||
case LibraryDensity.normal:
|
||||
return 13;
|
||||
case LibraryDensity.comfortable:
|
||||
return 14;
|
||||
}
|
||||
}
|
||||
double get _subtitleFontSize => 11 + LibraryDensity.factor(density) * 3; // 11–14
|
||||
|
||||
double get _summaryFontSize {
|
||||
// Summary uses the same sizing as metadata text
|
||||
return _metadataFontSize;
|
||||
}
|
||||
|
||||
int get _summaryMaxLines {
|
||||
switch (density) {
|
||||
case LibraryDensity.compact:
|
||||
return 2;
|
||||
case LibraryDensity.normal:
|
||||
return 3;
|
||||
case LibraryDensity.comfortable:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
int get _summaryMaxLines => density <= 2 ? 2 : density; // 2, 2, 3, 4, 5
|
||||
|
||||
String _buildMetadataLine() {
|
||||
final parts = <String>[];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
|
||||
@@ -13,7 +12,7 @@ class MediaGridDelegate {
|
||||
/// Set [useWideAspectRatio] to true to use 16:9 aspect ratio for episode thumbnails.
|
||||
static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({
|
||||
required BuildContext context,
|
||||
required LibraryDensity density,
|
||||
required int density,
|
||||
bool usePaddingAware = false,
|
||||
double horizontalPadding = 16,
|
||||
bool useWideAspectRatio = false,
|
||||
|
||||
Reference in New Issue
Block a user