diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index f30e84e6..9f78fb05 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Dark", "oledTheme": "OLED", "libraryDensity": "Library Density", + "displayScale": "Display Scale", "compact": "Compact", "comfortable": "Comfortable", "tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 597d9896..82305407 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32737 (1488 per locale) +/// Strings: 32738 (1488 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index bb1c32db..6a068ad2 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -438,6 +438,9 @@ class Translations$settings$en { /// en: 'Library Density' String get libraryDensity => 'Library Density'; + /// en: 'Display Scale' + String get displayScale => 'Display Scale'; + /// en: 'Compact' String get compact => 'Compact'; @@ -6208,6 +6211,7 @@ extension on Translations { 'settings.darkTheme' => 'Dark', 'settings.oledTheme' => 'OLED', 'settings.libraryDensity' => 'Library Density', + 'settings.displayScale' => 'Display Scale', 'settings.compact' => 'Compact', 'settings.comfortable' => 'Comfortable', 'settings.tvCornerSpotlightBackdrop' => 'Corner Spotlight Backdrop', @@ -6611,9 +6615,9 @@ extension on Translations { 'mediaMenu.deleteAnyway' => 'Delete anyway', 'mediaMenu.confirmDeleteTarget' => ({required Object title}) => 'Permanently delete ${title} from your server?', 'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.', - 'mediaMenu.deleteEpisodeCountWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This deletes all ${n} episode in it, and its file.', other: 'This deletes all ${n} episodes in it, and their files.', ), _ => null, } ?? switch (path) { + 'mediaMenu.deleteEpisodeCountWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This deletes all ${n} episode in it, and its file.', other: 'This deletes all ${n} episodes in it, and their files.', ), 'mediaMenu.deleteMultiPartWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This item is stored as ${n} file, which will be deleted.', other: 'This item is stored across ${n} files, and all of them will be deleted.', ), 'mediaMenu.deleteSharedFileHeading' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} other episode is stored in the same file and will be deleted too:', other: '${n} other episodes are stored in the same file and will be deleted too:', ), 'mediaMenu.deleteScopeUnverifiedProbeFailed' => 'Plezy could not check which files this will remove, so it may delete more than the item named above. Cancel and try again, or delete anyway.', @@ -7125,9 +7129,9 @@ extension on Translations { 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} seasons', 'explore.badge.nextEpisodeIn' => ({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}', 'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}', - 'explore.badge.episodesShort' => ({required Object n}) => '${n} eps', _ => null, } ?? switch (path) { + 'explore.badge.episodesShort' => ({required Object n}) => '${n} eps', 'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep', 'explore.badge.adult' => '18+', 'explore.stats.listed' => ({required Object n}) => '${n} listed', @@ -7639,9 +7643,9 @@ extension on Translations { 'performanceOverlay.maxCll' => 'MaxCLL', 'performanceOverlay.maxFall' => 'MaxFALL', 'performanceOverlay.cacheUsed' => 'Cache Used', - 'performanceOverlay.cacheLimit' => 'Cache Limit', _ => null, } ?? switch (path) { + 'performanceOverlay.cacheLimit' => 'Cache Limit', 'performanceOverlay.speed' => 'Speed', 'performanceOverlay.player' => 'Player', 'performanceOverlay.memory' => 'Memory', diff --git a/lib/main.dart b/lib/main.dart index 5d0fbaff..7c5d2044 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,6 +38,7 @@ import 'services/macos_window_service.dart'; import 'services/native_window_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/settings_service.dart'; +import 'widgets/settings_builder.dart'; import 'utils/platform_detector.dart'; import 'services/apple_tv_remote_touch_service.dart'; import 'services/discord_rpc_service.dart'; @@ -1677,13 +1678,7 @@ class _AppShell extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.browserBack): const DismissIntent(), const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(), }, - builder: (context, child) => ScaffoldMessenger( - key: rootScaffoldMessengerKey, - child: Scaffold( - backgroundColor: Colors.transparent, - body: _AppleTvScale(child: child), - ), - ), + builder: (context, child) => _rootShell(child), ), ), ); @@ -1695,47 +1690,85 @@ class _AppShell extends StatelessWidget { } } -/// On Apple TV the system hands Flutter a 1920×1080 surface at -/// devicePixelRatio 1.0, the same logical pixel count as a phablet. That's -/// too dense for a 10ft viewing distance, so everything ends up tiny. We -/// shrink the effective logical size to half and scale the rendered output -/// back up so fonts, icons, and paddings end up visually ~2× larger — roughly -/// matching the UI feel of Android TV (which renders at lower logical DPI). -class _AppleTvScale extends StatelessWidget { - final Widget? child; - const _AppleTvScale({required this.child}); +/// The root shell every route renders inside. +/// +/// The form-factor scale sits above the messenger and its root [Scaffold], not inside them: +/// Flutter presents a messenger's snackbars on the rootmost registered scaffold, so anything +/// below would leave global snackbars at the car's native density while the rest of the +/// interface grew. +Widget _rootShell(Widget? child) { + return _FormFactorScale( + child: ScaffoldMessenger( + key: rootScaffoldMessengerKey, + child: Scaffold(backgroundColor: Colors.transparent, body: child), + ), + ); +} - static const double _scale = 2.0; +/// Apple TV receives a full-HD logical surface, while Android Automotive can +/// report a very low display density. Both make otherwise comfortable controls +/// physically too small, so render through a smaller, self-consistent logical +/// viewport and scale the result back to the physical surface. +class _FormFactorScale extends StatelessWidget { + final Widget? child; + const _FormFactorScale({required this.child}); + + static const double _appleTvScale = 2.0; @override Widget build(BuildContext context) { - if (child == null || !PlatformDetector.isAppleTV()) { - return child ?? const SizedBox.shrink(); + final child = this.child; + if (child == null) return const SizedBox.shrink(); + + // Keep the existing Apple TV path independent of settings so its 2× + // behavior and overscan handling remain unchanged. + if (PlatformDetector.isAppleTV()) { + return _scaledSurface(child: child, scale: _appleTvScale, zeroInsets: true); } + if (!PlatformDetector.isAutomotive()) return child; + + return SettingValueBuilder( + pref: SettingsService.automotiveUiScale, + builder: (context, scale, _) => _scaledSurface(child: child, scale: scale, zeroInsets: false), + ); + } + + Widget _scaledSurface({required Widget child, required double scale, required bool zeroInsets}) { return LayoutBuilder( builder: (context, constraints) { - final logicalSize = Size(constraints.maxWidth / _scale, constraints.maxHeight / _scale); + final logicalSize = Size(constraints.maxWidth / scale, constraints.maxHeight / scale); final outerQ = MediaQuery.of(context); // tvOS reports conservative overscan insets (~60pt top/bottom, // ~90pt left/right). Modern TVs don't overscan, so treat them as // dead margin and zero them out — the UI can use the full surface. + // + // Automotive system bars are real touch-exclusion regions. Preserve + // their physical size in the scaled coordinate system so SafeArea + // continues to keep controls out from underneath them. return Transform.scale( - scale: _scale, + scale: scale, alignment: .topLeft, transformHitTests: true, - child: SizedBox( - width: logicalSize.width, - height: logicalSize.height, - child: MediaQuery( - data: outerQ.copyWith( - size: logicalSize, - devicePixelRatio: outerQ.devicePixelRatio * _scale, - padding: .zero, - viewPadding: .zero, - viewInsets: .zero, - systemGestureInsets: .zero, + // Align loosens what it passes down. Without it a tight incoming constraint — which is + // what the app's root hands its builder — forces the SizedBox back to the full surface, + // and the transform then only magnifies a full-size layout instead of rendering a + // smaller one into the same space. + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: logicalSize.width, + height: logicalSize.height, + child: MediaQuery( + data: outerQ.copyWith( + size: logicalSize, + devicePixelRatio: outerQ.devicePixelRatio * scale, + padding: zeroInsets ? .zero : outerQ.padding * (1 / scale), + viewPadding: zeroInsets ? .zero : outerQ.viewPadding * (1 / scale), + viewInsets: zeroInsets ? .zero : outerQ.viewInsets * (1 / scale), + systemGestureInsets: zeroInsets ? .zero : outerQ.systemGestureInsets * (1 / scale), + ), + child: child, ), - child: child!, ), ), ); @@ -1744,6 +1777,13 @@ class _AppleTvScale extends StatelessWidget { } } +@visibleForTesting +Widget formFactorScaleForTesting({required Widget? child}) => _FormFactorScale(child: child); + +/// The real root shell, so a test can assert what the scale actually encloses. +@visibleForTesting +Widget rootShellForTesting({required Widget? child}) => _rootShell(child); + @visibleForTesting bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) { return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired; @@ -2134,21 +2174,49 @@ class _SetupScreenState extends State with MountedSetStateMixin { @override Widget build(BuildContext context) { const coralColor = Color(0xFFE5A00D); + final height = MediaQuery.sizeOf(context).height; + // The stacked layout below hangs its two rows off fixed ±170/180 offsets from the middle, which + // needs roughly 700 logical pixels of height. A car at a large interface scale — and a phone in + // landscape — has less than that, and the rows would collide or fall outside the Stack's clip. + if (height < 700) { + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 160, height: 160), + _buildStatusText(context), + const SizedBox(height: 16), + Center( + child: _serverStatus.isEmpty + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: coralColor), + ) + : _buildServerStatusList(context), + ), + ], + ), + ), + ), + ), + ); + } return ColoredBox( color: Theme.of(context).scaffoldBackgroundColor, child: Stack( children: [ Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)), + Positioned(left: 0, right: 0, bottom: height * 0.5 - 170, child: _buildStatusText(context)), Positioned( left: 0, right: 0, - bottom: MediaQuery.sizeOf(context).height * 0.5 - 170, - child: _buildStatusText(context), - ), - Positioned( - left: 0, - right: 0, - top: MediaQuery.sizeOf(context).height * 0.5 + 180, + top: height * 0.5 + 180, child: Center( child: _serverStatus.isEmpty ? const SizedBox( diff --git a/lib/screens/music/now_playing_screen.dart b/lib/screens/music/now_playing_screen.dart index bc3450f6..a6e1e483 100644 --- a/lib/screens/music/now_playing_screen.dart +++ b/lib/screens/music/now_playing_screen.dart @@ -724,77 +724,74 @@ class _NowPlayingScreenState extends State } Widget _buildTransportRow(MusicPlaybackService service) { + final transport = FocusableActionBar( + spacing: 8, + onNavigateUp: _seekFocusNode.requestFocus, + onNavigateDown: () => _utilityBarKey.currentState?.requestFocusOnFirst(), + onBack: _pop, + actions: [ + FocusableAction( + debugLabel: 'np_shuffle', + onPressed: service.toggleShuffle, + builder: (context, state) => _transportIcon( + state, + icon: Symbols.shuffle_rounded, + active: service.shuffled, + tooltip: t.common.shuffle, + onPressed: service.toggleShuffle, + size: 22, + ), + ), + FocusableAction( + debugLabel: 'np_previous', + onPressed: () => unawaited(service.previous()), + builder: (context, state) => _transportIcon( + state, + icon: Symbols.skip_previous_rounded, + tooltip: t.music.previousTrack, + onPressed: () => unawaited(service.previous()), + size: 32, + ), + ), + FocusableAction( + debugLabel: 'np_play_pause', + focusNode: _playPauseFocusNode, + autofocus: PlatformDetector.isTV(), + onPressed: () => unawaited(service.togglePlayPause()), + builder: (context, state) => + _PlayPauseButton(state: state, onPressed: () => unawaited(service.togglePlayPause())), + ), + FocusableAction( + debugLabel: 'np_next', + onPressed: () => unawaited(service.next()), + builder: (context, state) => _transportIcon( + state, + icon: Symbols.skip_next_rounded, + tooltip: t.music.nextTrack, + onPressed: () => unawaited(service.next()), + size: 32, + ), + ), + FocusableAction( + debugLabel: 'np_repeat', + onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), + builder: (context, state) => _transportIcon( + state, + icon: repeatModeIcon(service.repeatMode), + active: service.repeatMode != MusicRepeatMode.off, + tooltip: repeatModeLabel(service.repeatMode), + onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), + size: 22, + ), + ), + ], + ); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center( - // Scale down instead of overflowing when the hosting column is - // narrower than the row's intrinsic width (e.g. TV layout on a - // narrow display or a small desktop window). - child: FittedBox( - fit: BoxFit.scaleDown, - child: FocusableActionBar( - spacing: 8, - onNavigateUp: _seekFocusNode.requestFocus, - onNavigateDown: () => _utilityBarKey.currentState?.requestFocusOnFirst(), - onBack: _pop, - actions: [ - FocusableAction( - debugLabel: 'np_shuffle', - onPressed: service.toggleShuffle, - builder: (context, state) => _transportIcon( - state, - icon: Symbols.shuffle_rounded, - active: service.shuffled, - tooltip: t.common.shuffle, - onPressed: service.toggleShuffle, - size: 22, - ), - ), - FocusableAction( - debugLabel: 'np_previous', - onPressed: () => unawaited(service.previous()), - builder: (context, state) => _transportIcon( - state, - icon: Symbols.skip_previous_rounded, - tooltip: t.music.previousTrack, - onPressed: () => unawaited(service.previous()), - size: 32, - ), - ), - FocusableAction( - debugLabel: 'np_play_pause', - focusNode: _playPauseFocusNode, - autofocus: PlatformDetector.isTV(), - onPressed: () => unawaited(service.togglePlayPause()), - builder: (context, state) => - _PlayPauseButton(state: state, onPressed: () => unawaited(service.togglePlayPause())), - ), - FocusableAction( - debugLabel: 'np_next', - onPressed: () => unawaited(service.next()), - builder: (context, state) => _transportIcon( - state, - icon: Symbols.skip_next_rounded, - tooltip: t.music.nextTrack, - onPressed: () => unawaited(service.next()), - size: 32, - ), - ), - FocusableAction( - debugLabel: 'np_repeat', - onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), - builder: (context, state) => _transportIcon( - state, - icon: repeatModeIcon(service.repeatMode), - active: service.repeatMode != MusicRepeatMode.off, - tooltip: repeatModeLabel(service.repeatMode), - onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), - size: 22, - ), - ), - ], - ), - ), + // Cars must retain the enlarged touch targets. Other form factors keep + // scaling the row down rather than overflowing narrow layouts. + child: PlatformDetector.isAutomotive() ? transport : FittedBox(fit: BoxFit.scaleDown, child: transport), ), ); } diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 894ac7ba..7f60ccb1 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -41,6 +41,7 @@ class AppearanceSettingsScreen extends StatelessWidget { _themeSelector(), _languageSelector(context), _densitySelector(), + if (PlatformDetector.isAutomotive()) _displayScaleSelector(), _viewModeSelector(), _episodePosterModeSelector(), if (PlatformDetector.isTV()) @@ -291,6 +292,39 @@ class AppearanceSettingsScreen extends StatelessWidget { ); } + Widget _displayScaleSelector() { + return SettingValueBuilder( + pref: SettingsService.automotiveUiScale, + builder: (context, scale, _) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + const AppIcon(Symbols.format_size_rounded, fill: 1), + const SizedBox(width: 16), + Text(t.settings.displayScale, style: settingsOptionTitleStyle(context)), + const Spacer(), + Text('${scale.toStringAsFixed(2)}×', style: Theme.of(context).textTheme.bodyMedium), + ], + ), + const SizedBox(height: 12), + FocusableSlider( + value: scale, + min: AutomotiveUiScale.min, + max: AutomotiveUiScale.max, + divisions: 20, + onChanged: (value) => SettingsService.instance.write(SettingsService.automotiveUiScale, value), + ), + ], + ), + ); + }, + ); + } + Widget _viewModeSelector() => SettingSegmentedTile( pref: SettingsService.viewMode, icon: Symbols.view_list_rounded, diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index dc298c7a..86f45dbe 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -121,6 +121,7 @@ class SettingsExportService { if (pref.key == SettingsService.appLocale.key) return _typeString; if (pref.key == SettingsService.libraryDensity.key) return _typeInt; + if (pref.key == SettingsService.automotiveUiScale.key) return _typeDouble; if (pref.key == SettingsService.autoPip.key || pref.key == SettingsService.useExternalPlayer.key || pref.key == SettingsService.audioPassthrough.key) { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 01d536d0..69118578 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -135,6 +135,31 @@ class _LibraryDensityPref extends Pref { svc.writeInt(key, value.clamp(LibraryDensity.min, LibraryDensity.max)); } +class AutomotiveUiScale { + static const double min = 1.0; + static const double max = 2.0; + static const double defaultValue = 1.35; +} + +/// Uses a larger default on car displays while honoring and clamping a stored +/// user adjustment on every platform. +class _AutomotiveUiScalePref extends Pref { + const _AutomotiveUiScalePref() : super('automotive_ui_scale'); + + @override + double readFrom(BaseSharedPreferencesService svc) { + final fallback = PlatformDetector.isAutomotive() ? AutomotiveUiScale.defaultValue : 1.0; + // Tolerant read, not `prefs.getDouble`: this is read while building the root + // app, so a mistyped stored value would turn every launch into the error + // widget instead of dropping the key (#1732). + return svc.readDouble(key, defaultValue: fallback).clamp(AutomotiveUiScale.min, AutomotiveUiScale.max).toDouble(); + } + + @override + Future writeTo(BaseSharedPreferencesService svc, double value) => + svc.writeDouble(key, value.clamp(AutomotiveUiScale.min, AutomotiveUiScale.max).toDouble()); +} + /// Migrates from the legacy `use_season_poster` boolean key. class _EpisodePosterModePref extends EnumPref { const _EpisodePosterModePref() @@ -480,6 +505,7 @@ class SettingsService extends BaseSharedPreferencesService { static const bufferSize = _BufferSizePref(); static const libraryDensity = _LibraryDensityPref(); + static const automotiveUiScale = _AutomotiveUiScalePref(); static const tvCornerSpotlightBackdrop = BoolPref('tv_corner_spotlight_backdrop'); static const episodePosterMode = _EpisodePosterModePref(); static const continueWatchingAction = EnumPref( @@ -907,6 +933,7 @@ class SettingsService extends BaseSharedPreferencesService { videoPlayerNavigationEnabled, bufferSize, libraryDensity, + automotiveUiScale, tvCornerSpotlightBackdrop, episodePosterMode, continueWatchingAction, diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index 49762eae..aad3b6aa 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -59,20 +59,14 @@ class GridSizeCalculator { /// to account for sidebars or other elements that reduce the grid's actual /// width. Never use a plain `SliverLayoutBuilder` for this: its constraints /// include the scroll offset, so it rebuilds the whole grid every scroll tick. - static int getColumnCount( - double crossAxisExtent, - double maxCrossAxisExtent, { - double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing, - }) { - return (crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); + static int getColumnCount(double crossAxisExtent, double maxCrossAxisExtent, {double? crossAxisSpacing}) { + final effectiveSpacing = crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing; + return (crossAxisExtent / (maxCrossAxisExtent + effectiveSpacing)).ceil().clamp(1, 100); } - static double getCellWidthForColumnCount( - double crossAxisExtent, - int columnCount, { - double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing, - }) { - return (crossAxisExtent - (crossAxisSpacing * (columnCount - 1))) / columnCount; + static double getCellWidthForColumnCount(double crossAxisExtent, int columnCount, {double? crossAxisSpacing}) { + final effectiveSpacing = crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing; + return (crossAxisExtent - (effectiveSpacing * (columnCount - 1))) / columnCount; } /// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index a3fef3d2..fa33cf03 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'platform_detector.dart'; /// Layout and sizing constants used throughout the application /// Screen width breakpoints for responsive design @@ -50,13 +51,14 @@ class GridLayoutConstants { /// reserves ([posterAspectRatio] adds 0.3 to the 2:3 image's denominator). static const double squareGridCellAspectRatio = 2 / 2.3; - static const double crossAxisSpacing = 0; - static const double mainAxisSpacing = 0; + static double get crossAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0; + static double get mainAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0; static double fullCardGridSpacingForScale(double scale) => (12 * scale).clamp(8, 18).toDouble(); /// Standard grid padding - static EdgeInsets get gridPadding => const EdgeInsets.only(left: 2, right: 2, bottom: 2); + static EdgeInsets get gridPadding => + PlatformDetector.isAutomotive() ? const EdgeInsets.all(24) : const EdgeInsets.only(left: 2, right: 2, bottom: 2); } class TvLayoutConstants { diff --git a/lib/widgets/fitting_title_text.dart b/lib/widgets/fitting_title_text.dart index d71ee290..d05b3fff 100644 --- a/lib/widgets/fitting_title_text.dart +++ b/lib/widgets/fitting_title_text.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/platform_detector.dart'; class FittingTitleText extends StatelessWidget { final String text; @@ -23,6 +24,12 @@ class FittingTitleText extends StatelessWidget { @override Widget build(BuildContext context) { final baseStyle = style ?? DefaultTextStyle.of(context).style; + if (PlatformDetector.isAutomotive()) { + return Align( + alignment: alignment, + child: Text(text, style: baseStyle, maxLines: maxLines, overflow: overflow, textAlign: textAlign), + ); + } return LayoutBuilder( builder: (context, constraints) { var fittedStyle = baseStyle; diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 709234b4..6646856f 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focusable_tile_mixin.dart'; +import '../utils/platform_detector.dart'; import 'clickable_cursor.dart'; /// A ListTile that accepts a FocusNode for keyboard/controller navigation. @@ -89,6 +90,7 @@ class _FocusableListTileState extends State with FocusableTil final needsContrastSwap = _isHoveredOrFocused && widget.hoverColor != null && widget.textColor != null; final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor; final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor; + final automotive = PlatformDetector.isAutomotive(); final Widget tile = MouseRegion( cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null) @@ -103,11 +105,11 @@ class _FocusableListTileState extends State with FocusableTil trailing: widget.trailing, onTap: widget.onTap, onLongPress: widget.onLongPress, - dense: widget.dense, + dense: automotive ? false : widget.dense, enabled: widget.enabled, selected: widget.selected, contentPadding: widget.contentPadding, - visualDensity: widget.visualDensity, + visualDensity: automotive ? VisualDensity.standard : widget.visualDensity, focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode, autofocus: widget.suppressInitialSelect ? false : widget.autofocus, hoverColor: widget.hoverColor, @@ -196,6 +198,7 @@ class _FocusableRadioListTileState extends State> @override Widget build(BuildContext context) { + final automotive = PlatformDetector.isAutomotive(); return ClickableCursor( enabled: widget.enabled ?? true, child: RadioListTile( @@ -204,8 +207,8 @@ class _FocusableRadioListTileState extends State> secondary: widget.secondary, value: widget.value, // groupValue and onChanged provided by RadioGroup ancestor - dense: widget.dense, - visualDensity: widget.visualDensity, + dense: automotive ? false : widget.dense, + visualDensity: automotive ? VisualDensity.standard : widget.visualDensity, focusNode: effectiveFocusNode, autofocus: widget.autofocus, enabled: widget.enabled, @@ -282,6 +285,7 @@ class _FocusableSwitchListTileState extends State @override Widget build(BuildContext context) { + final automotive = PlatformDetector.isAutomotive(); return ClickableCursor( enabled: widget.onChanged != null, child: SwitchListTile( @@ -290,8 +294,8 @@ class _FocusableSwitchListTileState extends State secondary: widget.secondary, value: widget.value, onChanged: widget.onChanged, - dense: widget.dense, - visualDensity: widget.visualDensity, + dense: automotive ? false : widget.dense, + visualDensity: automotive ? VisualDensity.standard : widget.visualDensity, contentPadding: widget.contentPadding, focusNode: effectiveFocusNode, autofocus: widget.autofocus, @@ -346,6 +350,7 @@ class _FocusableCheckboxListTileState extends State @override Widget build(BuildContext context) { + final automotive = PlatformDetector.isAutomotive(); return ClickableCursor( enabled: widget.onChanged != null, child: CheckboxListTile( @@ -355,8 +360,8 @@ class _FocusableCheckboxListTileState extends State value: widget.value, onChanged: widget.onChanged, tristate: widget.tristate, - dense: widget.dense, - visualDensity: widget.visualDensity, + dense: automotive ? false : widget.dense, + visualDensity: automotive ? VisualDensity.standard : widget.visualDensity, contentPadding: widget.contentPadding, focusNode: effectiveFocusNode, autofocus: widget.autofocus, diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index d07ce530..90b8d2dc 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -2,6 +2,7 @@ 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 delegate configuration for media item grids /// Maintains consistent aspect ratio and spacing across all media grids. @@ -81,6 +82,7 @@ class MediaGridDelegate { } static double spacingFor({required BuildContext context, bool fullBleedImage = false}) { + if (PlatformDetector.isAutomotive()) return GridLayoutConstants.crossAxisSpacing; if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing; return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context)); } diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index 6cc7482b..9817a4c5 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../theme/mono_tokens.dart'; +import '../utils/platform_detector.dart'; import 'app_icon.dart'; import 'expressive_button_group.dart'; @@ -70,7 +71,7 @@ class SettingsGroup extends StatelessWidget { Padding( padding: margin, child: ListTileTheme.merge( - visualDensity: const VisualDensity(vertical: -3), + visualDensity: PlatformDetector.isAutomotive() ? VisualDensity.standard : const VisualDensity(vertical: -3), child: Column( children: [ for (var i = 0; i < children.length; i++) ...[ diff --git a/lib/widgets/system_bottom_inset.dart b/lib/widgets/system_bottom_inset.dart index 767b28f8..cb7bd63c 100644 --- a/lib/widgets/system_bottom_inset.dart +++ b/lib/widgets/system_bottom_inset.dart @@ -17,7 +17,7 @@ import 'package:flutter/widgets.dart'; /// /// Collapses to zero height wherever the bottom padding is already zero, so it /// needs no platform branching: desktop and Android TV report no inset, tvOS -/// has it zeroed by `_AppleTvScale`, and inside `MainScreen`'s tab bodies +/// has it zeroed by `_FormFactorScale`, and inside `MainScreen`'s tab bodies /// Flutter's [Scaffold] has already stripped it because a `bottomNavigationBar` /// is present. /// diff --git a/test/screens/car_ui_scale_test.dart b/test/screens/car_ui_scale_test.dart new file mode 100644 index 00000000..da2bff31 --- /dev/null +++ b/test/screens/car_ui_scale_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/main.dart' as app; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/utils/snackbar_helper.dart'; +import 'package:plezy/widgets/focusable_list_tile.dart'; + +import '../test_helpers/prefs.dart'; +import '../test_helpers/theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + TvDetectionService.debugSetAppleTVOverride(false); + TvDetectionService.debugSetAutomotiveOverride(false); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.debugSetAutomotiveOverride(null); + SettingsService.resetForTesting(); + }); + + testWidgets('a root snackbar is laid out inside the car scale, not outside it', (tester) async { + TvDetectionService.debugSetAutomotiveOverride(true); + tester.view.physicalSize = const Size(1080, 600); + tester.view.devicePixelRatio = 0.75; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: ThemeData(extensions: const [testMonoTokens]), + home: const SizedBox.expand(), + builder: (context, child) => app.rootShellForTesting(child: child), + ), + ), + ); + + rootScaffoldMessengerKey.currentState!.showSnackBar(const SnackBar(content: Text('scaled'))); + await tester.pumpAndSettle(); + + // The unscaled surface is 1440 logical pixels wide; the default car scale renders into + // 1440 / 1.35, so a snackbar outside the transform would measure the full width. + final width = tester.getSize(find.byType(SnackBar)).width; + expect(width, lessThan(1440 * 0.9), reason: 'a root snackbar must inherit the scaled viewport'); + expect(width, closeTo(1440 / 1.35, 1.0)); + }); + + test('automotive UI scale defaults by form factor and honors a stored value', () async { + final settings = SettingsService.instance; + + expect(settings.read(SettingsService.automotiveUiScale), 1.0); + + TvDetectionService.debugSetAutomotiveOverride(true); + expect(settings.read(SettingsService.automotiveUiScale), AutomotiveUiScale.defaultValue); + + await settings.write(SettingsService.automotiveUiScale, 1.6); + expect(settings.read(SettingsService.automotiveUiScale), 1.6); + + TvDetectionService.debugSetAutomotiveOverride(false); + expect(settings.read(SettingsService.automotiveUiScale), 1.6); + + await settings.write(SettingsService.automotiveUiScale, 0.5); + expect(settings.read(SettingsService.automotiveUiScale), AutomotiveUiScale.min); + + await settings.write(SettingsService.automotiveUiScale, 2.5); + expect(settings.read(SettingsService.automotiveUiScale), AutomotiveUiScale.max); + }); + + testWidgets('automotive root scale rewrites MediaQuery and updates reactively', (tester) async { + _configureCarViewport(tester); + TvDetectionService.debugSetAutomotiveOverride(true); + + var effectiveSize = Size.zero; + var effectiveDevicePixelRatio = 0.0; + await _pumpScaleHarness( + tester, + onMediaQuery: (data) { + effectiveSize = data.size; + effectiveDevicePixelRatio = data.devicePixelRatio; + }, + ); + + expect(effectiveSize.width, closeTo(1440 / AutomotiveUiScale.defaultValue, 0.001)); + expect(effectiveSize.height, closeTo(800 / AutomotiveUiScale.defaultValue, 0.001)); + expect(effectiveDevicePixelRatio, closeTo(0.75 * AutomotiveUiScale.defaultValue, 0.001)); + + await SettingsService.instance.write(SettingsService.automotiveUiScale, 1.5); + await tester.pump(); + + expect(effectiveSize, const Size(960, 800 / 1.5)); + expect(effectiveDevicePixelRatio, 1.125); + }); + + testWidgets('non-automotive root leaves MediaQuery unchanged', (tester) async { + _configureCarViewport(tester); + + var effectiveSize = Size.zero; + var effectiveDevicePixelRatio = 0.0; + await _pumpScaleHarness( + tester, + onMediaQuery: (data) { + effectiveSize = data.size; + effectiveDevicePixelRatio = data.devicePixelRatio; + }, + ); + + expect(effectiveSize, const Size(1440, 800)); + expect(effectiveDevicePixelRatio, 0.75); + }); + + testWidgets('focusable list tile variants use standard density only on automotive', (tester) async { + await _pumpListTiles(tester, automotive: false); + _expectListTileDensities(tester, dense: true, visualDensity: const VisualDensity(vertical: -3)); + + await _pumpListTiles(tester, automotive: true); + _expectListTileDensities(tester, dense: false, visualDensity: VisualDensity.standard); + }); +} + +void _configureCarViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1080, 600); + tester.view.devicePixelRatio = 0.75; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +Future _pumpScaleHarness(WidgetTester tester, {required ValueChanged onMediaQuery}) async { + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: ThemeData(extensions: const [testMonoTokens]), + home: app.formFactorScaleForTesting( + child: Builder( + builder: (context) { + onMediaQuery(MediaQuery.of(context)); + return const SizedBox.expand(); + }, + ), + ), + ), + ), + ); +} + +Future _pumpListTiles(WidgetTester tester, {required bool automotive}) async { + TvDetectionService.debugSetAutomotiveOverride(automotive); + // Unmount first: the tiles below are `const`, so pumping the same tree twice + // hands the framework identical widget instances and the subtree is never + // rebuilt — it would keep the density of the previous form factor. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: ThemeData(extensions: const [testMonoTokens]), + home: Scaffold( + body: RadioGroup( + groupValue: 1, + onChanged: (_) {}, + child: Column( + children: [ + const FocusableListTile(title: Text('List')), + const FocusableRadioListTile(value: 1, title: Text('Radio')), + FocusableSwitchListTile(value: true, onChanged: (_) {}, title: const Text('Switch')), + FocusableCheckboxListTile(value: true, onChanged: (_) {}, title: const Text('Checkbox')), + ], + ), + ), + ), + ), + ), + ); +} + +void _expectListTileDensities(WidgetTester tester, {required bool dense, required VisualDensity visualDensity}) { + final listTile = tester.widget( + find.descendant(of: find.byType(FocusableListTile), matching: find.byType(ListTile)), + ); + final radioTile = tester.widget>(find.byType(RadioListTile)); + final switchTile = tester.widget(find.byType(SwitchListTile)); + final checkboxTile = tester.widget(find.byType(CheckboxListTile)); + + expect(listTile.dense, dense); + expect(listTile.visualDensity, visualDensity); + expect(radioTile.dense, dense); + expect(radioTile.visualDensity, visualDensity); + expect(switchTile.dense, dense); + expect(switchTile.visualDensity, visualDensity); + expect(checkboxTile.dense, dense); + expect(checkboxTile.visualDensity, visualDensity); +}