feat(automotive): scale the car interface and make it adjustable

A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent 4607d165fd
commit 961e9c0326
16 changed files with 483 additions and 137 deletions
+1
View File
@@ -120,6 +120,7 @@
"darkTheme": "Dark",
"oledTheme": "OLED",
"libraryDensity": "Library Density",
"displayScale": "Display Scale",
"compact": "Compact",
"comfortable": "Comfortable",
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
+1 -1
View File
@@ -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
+7 -3
View File
@@ -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',
+108 -40
View File
@@ -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<double>(
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<SetupScreen> 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(
+65 -68
View File
@@ -724,77 +724,74 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
}
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),
),
);
}
@@ -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<double>(
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<ViewMode>(
pref: SettingsService.viewMode,
icon: Symbols.view_list_rounded,
@@ -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) {
+27
View File
@@ -135,6 +135,31 @@ class _LibraryDensityPref extends Pref<int> {
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<double> {
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<void> 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<EpisodePosterMode> {
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<ContinueWatchingAction>(
@@ -907,6 +933,7 @@ class SettingsService extends BaseSharedPreferencesService {
videoPlayerNavigationEnabled,
bufferSize,
libraryDensity,
automotiveUiScale,
tvCornerSpotlightBackdrop,
episodePosterMode,
continueWatchingAction,
+6 -12
View File
@@ -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
+5 -3
View File
@@ -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 {
+7
View File
@@ -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;
+13 -8
View File
@@ -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<FocusableListTile> 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<FocusableListTile> 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<T> extends State<FocusableRadioListTile<T>>
@override
Widget build(BuildContext context) {
final automotive = PlatformDetector.isAutomotive();
return ClickableCursor(
enabled: widget.enabled ?? true,
child: RadioListTile<T>(
@@ -204,8 +207,8 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
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<FocusableSwitchListTile>
@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<FocusableSwitchListTile>
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<FocusableCheckboxListTile>
@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<FocusableCheckboxListTile>
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,
+2
View File
@@ -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));
}
+2 -1
View File
@@ -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++) ...[
+1 -1
View File
@@ -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.
///
+203
View File
@@ -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<void> _pumpScaleHarness(WidgetTester tester, {required ValueChanged<MediaQueryData> 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<void> _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<int>(
groupValue: 1,
onChanged: (_) {},
child: Column(
children: [
const FocusableListTile(title: Text('List')),
const FocusableRadioListTile<int>(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<ListTile>(
find.descendant(of: find.byType(FocusableListTile), matching: find.byType(ListTile)),
);
final radioTile = tester.widget<RadioListTile<int>>(find.byType(RadioListTile<int>));
final switchTile = tester.widget<SwitchListTile>(find.byType(SwitchListTile));
final checkboxTile = tester.widget<CheckboxListTile>(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);
}