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:
@@ -120,6 +120,7 @@
|
|||||||
"darkTheme": "Dark",
|
"darkTheme": "Dark",
|
||||||
"oledTheme": "OLED",
|
"oledTheme": "OLED",
|
||||||
"libraryDensity": "Library Density",
|
"libraryDensity": "Library Density",
|
||||||
|
"displayScale": "Display Scale",
|
||||||
"compact": "Compact",
|
"compact": "Compact",
|
||||||
"comfortable": "Comfortable",
|
"comfortable": "Comfortable",
|
||||||
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
|
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 22
|
/// Locales: 22
|
||||||
/// Strings: 32737 (1488 per locale)
|
/// Strings: 32738 (1488 per locale)
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -438,6 +438,9 @@ class Translations$settings$en {
|
|||||||
/// en: 'Library Density'
|
/// en: 'Library Density'
|
||||||
String get libraryDensity => 'Library Density';
|
String get libraryDensity => 'Library Density';
|
||||||
|
|
||||||
|
/// en: 'Display Scale'
|
||||||
|
String get displayScale => 'Display Scale';
|
||||||
|
|
||||||
/// en: 'Compact'
|
/// en: 'Compact'
|
||||||
String get compact => 'Compact';
|
String get compact => 'Compact';
|
||||||
|
|
||||||
@@ -6208,6 +6211,7 @@ extension on Translations {
|
|||||||
'settings.darkTheme' => 'Dark',
|
'settings.darkTheme' => 'Dark',
|
||||||
'settings.oledTheme' => 'OLED',
|
'settings.oledTheme' => 'OLED',
|
||||||
'settings.libraryDensity' => 'Library Density',
|
'settings.libraryDensity' => 'Library Density',
|
||||||
|
'settings.displayScale' => 'Display Scale',
|
||||||
'settings.compact' => 'Compact',
|
'settings.compact' => 'Compact',
|
||||||
'settings.comfortable' => 'Comfortable',
|
'settings.comfortable' => 'Comfortable',
|
||||||
'settings.tvCornerSpotlightBackdrop' => 'Corner Spotlight Backdrop',
|
'settings.tvCornerSpotlightBackdrop' => 'Corner Spotlight Backdrop',
|
||||||
@@ -6611,9 +6615,9 @@ extension on Translations {
|
|||||||
'mediaMenu.deleteAnyway' => 'Delete anyway',
|
'mediaMenu.deleteAnyway' => 'Delete anyway',
|
||||||
'mediaMenu.confirmDeleteTarget' => ({required Object title}) => 'Permanently delete ${title} from your server?',
|
'mediaMenu.confirmDeleteTarget' => ({required Object title}) => 'Permanently delete ${title} from your server?',
|
||||||
'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.',
|
'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,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? 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.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.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.',
|
'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.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.nextEpisodeIn' => ({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}',
|
||||||
'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}',
|
'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}',
|
||||||
'explore.badge.episodesShort' => ({required Object n}) => '${n} eps',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'explore.badge.episodesShort' => ({required Object n}) => '${n} eps',
|
||||||
'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep',
|
'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep',
|
||||||
'explore.badge.adult' => '18+',
|
'explore.badge.adult' => '18+',
|
||||||
'explore.stats.listed' => ({required Object n}) => '${n} listed',
|
'explore.stats.listed' => ({required Object n}) => '${n} listed',
|
||||||
@@ -7639,9 +7643,9 @@ extension on Translations {
|
|||||||
'performanceOverlay.maxCll' => 'MaxCLL',
|
'performanceOverlay.maxCll' => 'MaxCLL',
|
||||||
'performanceOverlay.maxFall' => 'MaxFALL',
|
'performanceOverlay.maxFall' => 'MaxFALL',
|
||||||
'performanceOverlay.cacheUsed' => 'Cache Used',
|
'performanceOverlay.cacheUsed' => 'Cache Used',
|
||||||
'performanceOverlay.cacheLimit' => 'Cache Limit',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'performanceOverlay.cacheLimit' => 'Cache Limit',
|
||||||
'performanceOverlay.speed' => 'Speed',
|
'performanceOverlay.speed' => 'Speed',
|
||||||
'performanceOverlay.player' => 'Player',
|
'performanceOverlay.player' => 'Player',
|
||||||
'performanceOverlay.memory' => 'Memory',
|
'performanceOverlay.memory' => 'Memory',
|
||||||
|
|||||||
+108
-40
@@ -38,6 +38,7 @@ import 'services/macos_window_service.dart';
|
|||||||
import 'services/native_window_service.dart';
|
import 'services/native_window_service.dart';
|
||||||
import 'services/fullscreen_state_manager.dart';
|
import 'services/fullscreen_state_manager.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
|
import 'widgets/settings_builder.dart';
|
||||||
import 'utils/platform_detector.dart';
|
import 'utils/platform_detector.dart';
|
||||||
import 'services/apple_tv_remote_touch_service.dart';
|
import 'services/apple_tv_remote_touch_service.dart';
|
||||||
import 'services/discord_rpc_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.browserBack): const DismissIntent(),
|
||||||
const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(),
|
const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(),
|
||||||
},
|
},
|
||||||
builder: (context, child) => ScaffoldMessenger(
|
builder: (context, child) => _rootShell(child),
|
||||||
key: rootScaffoldMessengerKey,
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: _AppleTvScale(child: child),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1695,47 +1690,85 @@ class _AppShell extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On Apple TV the system hands Flutter a 1920×1080 surface at
|
/// The root shell every route renders inside.
|
||||||
/// 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
|
/// The form-factor scale sits above the messenger and its root [Scaffold], not inside them:
|
||||||
/// shrink the effective logical size to half and scale the rendered output
|
/// Flutter presents a messenger's snackbars on the rootmost registered scaffold, so anything
|
||||||
/// back up so fonts, icons, and paddings end up visually ~2× larger — roughly
|
/// below would leave global snackbars at the car's native density while the rest of the
|
||||||
/// matching the UI feel of Android TV (which renders at lower logical DPI).
|
/// interface grew.
|
||||||
class _AppleTvScale extends StatelessWidget {
|
Widget _rootShell(Widget? child) {
|
||||||
final Widget? child;
|
return _FormFactorScale(
|
||||||
const _AppleTvScale({required this.child});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (child == null || !PlatformDetector.isAppleTV()) {
|
final child = this.child;
|
||||||
return child ?? const SizedBox.shrink();
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
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);
|
final outerQ = MediaQuery.of(context);
|
||||||
// tvOS reports conservative overscan insets (~60pt top/bottom,
|
// tvOS reports conservative overscan insets (~60pt top/bottom,
|
||||||
// ~90pt left/right). Modern TVs don't overscan, so treat them as
|
// ~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.
|
// 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(
|
return Transform.scale(
|
||||||
scale: _scale,
|
scale: scale,
|
||||||
alignment: .topLeft,
|
alignment: .topLeft,
|
||||||
transformHitTests: true,
|
transformHitTests: true,
|
||||||
child: SizedBox(
|
// Align loosens what it passes down. Without it a tight incoming constraint — which is
|
||||||
width: logicalSize.width,
|
// what the app's root hands its builder — forces the SizedBox back to the full surface,
|
||||||
height: logicalSize.height,
|
// and the transform then only magnifies a full-size layout instead of rendering a
|
||||||
child: MediaQuery(
|
// smaller one into the same space.
|
||||||
data: outerQ.copyWith(
|
child: Align(
|
||||||
size: logicalSize,
|
alignment: Alignment.topLeft,
|
||||||
devicePixelRatio: outerQ.devicePixelRatio * _scale,
|
child: SizedBox(
|
||||||
padding: .zero,
|
width: logicalSize.width,
|
||||||
viewPadding: .zero,
|
height: logicalSize.height,
|
||||||
viewInsets: .zero,
|
child: MediaQuery(
|
||||||
systemGestureInsets: .zero,
|
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
|
@visibleForTesting
|
||||||
bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
|
bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
|
||||||
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
|
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||||
@@ -2134,21 +2174,49 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const coralColor = Color(0xFFE5A00D);
|
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(
|
return ColoredBox(
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
|
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(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: MediaQuery.sizeOf(context).height * 0.5 - 170,
|
top: height * 0.5 + 180,
|
||||||
child: _buildStatusText(context),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: MediaQuery.sizeOf(context).height * 0.5 + 180,
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _serverStatus.isEmpty
|
child: _serverStatus.isEmpty
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
|
|||||||
@@ -724,77 +724,74 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTransportRow(MusicPlaybackService service) {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Center(
|
child: Center(
|
||||||
// Scale down instead of overflowing when the hosting column is
|
// Cars must retain the enlarged touch targets. Other form factors keep
|
||||||
// narrower than the row's intrinsic width (e.g. TV layout on a
|
// scaling the row down rather than overflowing narrow layouts.
|
||||||
// narrow display or a small desktop window).
|
child: PlatformDetector.isAutomotive() ? transport : FittedBox(fit: BoxFit.scaleDown, child: transport),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
_themeSelector(),
|
_themeSelector(),
|
||||||
_languageSelector(context),
|
_languageSelector(context),
|
||||||
_densitySelector(),
|
_densitySelector(),
|
||||||
|
if (PlatformDetector.isAutomotive()) _displayScaleSelector(),
|
||||||
_viewModeSelector(),
|
_viewModeSelector(),
|
||||||
_episodePosterModeSelector(),
|
_episodePosterModeSelector(),
|
||||||
if (PlatformDetector.isTV())
|
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>(
|
Widget _viewModeSelector() => SettingSegmentedTile<ViewMode>(
|
||||||
pref: SettingsService.viewMode,
|
pref: SettingsService.viewMode,
|
||||||
icon: Symbols.view_list_rounded,
|
icon: Symbols.view_list_rounded,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class SettingsExportService {
|
|||||||
|
|
||||||
if (pref.key == SettingsService.appLocale.key) return _typeString;
|
if (pref.key == SettingsService.appLocale.key) return _typeString;
|
||||||
if (pref.key == SettingsService.libraryDensity.key) return _typeInt;
|
if (pref.key == SettingsService.libraryDensity.key) return _typeInt;
|
||||||
|
if (pref.key == SettingsService.automotiveUiScale.key) return _typeDouble;
|
||||||
if (pref.key == SettingsService.autoPip.key ||
|
if (pref.key == SettingsService.autoPip.key ||
|
||||||
pref.key == SettingsService.useExternalPlayer.key ||
|
pref.key == SettingsService.useExternalPlayer.key ||
|
||||||
pref.key == SettingsService.audioPassthrough.key) {
|
pref.key == SettingsService.audioPassthrough.key) {
|
||||||
|
|||||||
@@ -135,6 +135,31 @@ class _LibraryDensityPref extends Pref<int> {
|
|||||||
svc.writeInt(key, value.clamp(LibraryDensity.min, LibraryDensity.max));
|
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.
|
/// Migrates from the legacy `use_season_poster` boolean key.
|
||||||
class _EpisodePosterModePref extends EnumPref<EpisodePosterMode> {
|
class _EpisodePosterModePref extends EnumPref<EpisodePosterMode> {
|
||||||
const _EpisodePosterModePref()
|
const _EpisodePosterModePref()
|
||||||
@@ -480,6 +505,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
|||||||
|
|
||||||
static const bufferSize = _BufferSizePref();
|
static const bufferSize = _BufferSizePref();
|
||||||
static const libraryDensity = _LibraryDensityPref();
|
static const libraryDensity = _LibraryDensityPref();
|
||||||
|
static const automotiveUiScale = _AutomotiveUiScalePref();
|
||||||
static const tvCornerSpotlightBackdrop = BoolPref('tv_corner_spotlight_backdrop');
|
static const tvCornerSpotlightBackdrop = BoolPref('tv_corner_spotlight_backdrop');
|
||||||
static const episodePosterMode = _EpisodePosterModePref();
|
static const episodePosterMode = _EpisodePosterModePref();
|
||||||
static const continueWatchingAction = EnumPref<ContinueWatchingAction>(
|
static const continueWatchingAction = EnumPref<ContinueWatchingAction>(
|
||||||
@@ -907,6 +933,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
|||||||
videoPlayerNavigationEnabled,
|
videoPlayerNavigationEnabled,
|
||||||
bufferSize,
|
bufferSize,
|
||||||
libraryDensity,
|
libraryDensity,
|
||||||
|
automotiveUiScale,
|
||||||
tvCornerSpotlightBackdrop,
|
tvCornerSpotlightBackdrop,
|
||||||
episodePosterMode,
|
episodePosterMode,
|
||||||
continueWatchingAction,
|
continueWatchingAction,
|
||||||
|
|||||||
@@ -59,20 +59,14 @@ class GridSizeCalculator {
|
|||||||
/// to account for sidebars or other elements that reduce the grid's actual
|
/// to account for sidebars or other elements that reduce the grid's actual
|
||||||
/// width. Never use a plain `SliverLayoutBuilder` for this: its constraints
|
/// width. Never use a plain `SliverLayoutBuilder` for this: its constraints
|
||||||
/// include the scroll offset, so it rebuilds the whole grid every scroll tick.
|
/// include the scroll offset, so it rebuilds the whole grid every scroll tick.
|
||||||
static int getColumnCount(
|
static int getColumnCount(double crossAxisExtent, double maxCrossAxisExtent, {double? crossAxisSpacing}) {
|
||||||
double crossAxisExtent,
|
final effectiveSpacing = crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing;
|
||||||
double maxCrossAxisExtent, {
|
return (crossAxisExtent / (maxCrossAxisExtent + effectiveSpacing)).ceil().clamp(1, 100);
|
||||||
double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing,
|
|
||||||
}) {
|
|
||||||
return (crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static double getCellWidthForColumnCount(
|
static double getCellWidthForColumnCount(double crossAxisExtent, int columnCount, {double? crossAxisSpacing}) {
|
||||||
double crossAxisExtent,
|
final effectiveSpacing = crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing;
|
||||||
int columnCount, {
|
return (crossAxisExtent - (effectiveSpacing * (columnCount - 1))) / columnCount;
|
||||||
double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing,
|
|
||||||
}) {
|
|
||||||
return (crossAxisExtent - (crossAxisSpacing * (columnCount - 1))) / columnCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce
|
/// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'platform_detector.dart';
|
||||||
|
|
||||||
/// Layout and sizing constants used throughout the application
|
/// Layout and sizing constants used throughout the application
|
||||||
/// Screen width breakpoints for responsive design
|
/// Screen width breakpoints for responsive design
|
||||||
@@ -50,13 +51,14 @@ class GridLayoutConstants {
|
|||||||
/// reserves ([posterAspectRatio] adds 0.3 to the 2:3 image's denominator).
|
/// reserves ([posterAspectRatio] adds 0.3 to the 2:3 image's denominator).
|
||||||
static const double squareGridCellAspectRatio = 2 / 2.3;
|
static const double squareGridCellAspectRatio = 2 / 2.3;
|
||||||
|
|
||||||
static const double crossAxisSpacing = 0;
|
static double get crossAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0;
|
||||||
static const double mainAxisSpacing = 0;
|
static double get mainAxisSpacing => PlatformDetector.isAutomotive() ? 24 : 0;
|
||||||
|
|
||||||
static double fullCardGridSpacingForScale(double scale) => (12 * scale).clamp(8, 18).toDouble();
|
static double fullCardGridSpacingForScale(double scale) => (12 * scale).clamp(8, 18).toDouble();
|
||||||
|
|
||||||
/// Standard grid padding
|
/// 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 {
|
class TvLayoutConstants {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
|
|
||||||
class FittingTitleText extends StatelessWidget {
|
class FittingTitleText extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
@@ -23,6 +24,12 @@ class FittingTitleText extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final baseStyle = style ?? DefaultTextStyle.of(context).style;
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
var fittedStyle = baseStyle;
|
var fittedStyle = baseStyle;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../focus/dpad_navigator.dart';
|
import '../focus/dpad_navigator.dart';
|
||||||
import '../focus/focusable_tile_mixin.dart';
|
import '../focus/focusable_tile_mixin.dart';
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
import 'clickable_cursor.dart';
|
import 'clickable_cursor.dart';
|
||||||
|
|
||||||
/// A ListTile that accepts a FocusNode for keyboard/controller navigation.
|
/// 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 needsContrastSwap = _isHoveredOrFocused && widget.hoverColor != null && widget.textColor != null;
|
||||||
final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor;
|
final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor;
|
||||||
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
|
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
|
||||||
|
final automotive = PlatformDetector.isAutomotive();
|
||||||
|
|
||||||
final Widget tile = MouseRegion(
|
final Widget tile = MouseRegion(
|
||||||
cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null)
|
cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null)
|
||||||
@@ -103,11 +105,11 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
|
|||||||
trailing: widget.trailing,
|
trailing: widget.trailing,
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
onLongPress: widget.onLongPress,
|
onLongPress: widget.onLongPress,
|
||||||
dense: widget.dense,
|
dense: automotive ? false : widget.dense,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
selected: widget.selected,
|
selected: widget.selected,
|
||||||
contentPadding: widget.contentPadding,
|
contentPadding: widget.contentPadding,
|
||||||
visualDensity: widget.visualDensity,
|
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||||
focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode,
|
focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode,
|
||||||
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
|
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
|
||||||
hoverColor: widget.hoverColor,
|
hoverColor: widget.hoverColor,
|
||||||
@@ -196,6 +198,7 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final automotive = PlatformDetector.isAutomotive();
|
||||||
return ClickableCursor(
|
return ClickableCursor(
|
||||||
enabled: widget.enabled ?? true,
|
enabled: widget.enabled ?? true,
|
||||||
child: RadioListTile<T>(
|
child: RadioListTile<T>(
|
||||||
@@ -204,8 +207,8 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
|
|||||||
secondary: widget.secondary,
|
secondary: widget.secondary,
|
||||||
value: widget.value,
|
value: widget.value,
|
||||||
// groupValue and onChanged provided by RadioGroup ancestor
|
// groupValue and onChanged provided by RadioGroup ancestor
|
||||||
dense: widget.dense,
|
dense: automotive ? false : widget.dense,
|
||||||
visualDensity: widget.visualDensity,
|
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||||
focusNode: effectiveFocusNode,
|
focusNode: effectiveFocusNode,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
@@ -282,6 +285,7 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final automotive = PlatformDetector.isAutomotive();
|
||||||
return ClickableCursor(
|
return ClickableCursor(
|
||||||
enabled: widget.onChanged != null,
|
enabled: widget.onChanged != null,
|
||||||
child: SwitchListTile(
|
child: SwitchListTile(
|
||||||
@@ -290,8 +294,8 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
|||||||
secondary: widget.secondary,
|
secondary: widget.secondary,
|
||||||
value: widget.value,
|
value: widget.value,
|
||||||
onChanged: widget.onChanged,
|
onChanged: widget.onChanged,
|
||||||
dense: widget.dense,
|
dense: automotive ? false : widget.dense,
|
||||||
visualDensity: widget.visualDensity,
|
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||||
contentPadding: widget.contentPadding,
|
contentPadding: widget.contentPadding,
|
||||||
focusNode: effectiveFocusNode,
|
focusNode: effectiveFocusNode,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
@@ -346,6 +350,7 @@ class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final automotive = PlatformDetector.isAutomotive();
|
||||||
return ClickableCursor(
|
return ClickableCursor(
|
||||||
enabled: widget.onChanged != null,
|
enabled: widget.onChanged != null,
|
||||||
child: CheckboxListTile(
|
child: CheckboxListTile(
|
||||||
@@ -355,8 +360,8 @@ class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
|
|||||||
value: widget.value,
|
value: widget.value,
|
||||||
onChanged: widget.onChanged,
|
onChanged: widget.onChanged,
|
||||||
tristate: widget.tristate,
|
tristate: widget.tristate,
|
||||||
dense: widget.dense,
|
dense: automotive ? false : widget.dense,
|
||||||
visualDensity: widget.visualDensity,
|
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||||
contentPadding: widget.contentPadding,
|
contentPadding: widget.contentPadding,
|
||||||
focusNode: effectiveFocusNode,
|
focusNode: effectiveFocusNode,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../media/media_item.dart' show CardShape;
|
import '../media/media_item.dart' show CardShape;
|
||||||
import '../utils/grid_size_calculator.dart';
|
import '../utils/grid_size_calculator.dart';
|
||||||
import '../utils/layout_constants.dart';
|
import '../utils/layout_constants.dart';
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
|
|
||||||
/// Shared grid delegate configuration for media item grids
|
/// Shared grid delegate configuration for media item grids
|
||||||
/// Maintains consistent aspect ratio and spacing across all media 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}) {
|
static double spacingFor({required BuildContext context, bool fullBleedImage = false}) {
|
||||||
|
if (PlatformDetector.isAutomotive()) return GridLayoutConstants.crossAxisSpacing;
|
||||||
if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing;
|
if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing;
|
||||||
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
|
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/mono_tokens.dart';
|
import '../theme/mono_tokens.dart';
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
import 'app_icon.dart';
|
import 'app_icon.dart';
|
||||||
import 'expressive_button_group.dart';
|
import 'expressive_button_group.dart';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ class SettingsGroup extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: margin,
|
padding: margin,
|
||||||
child: ListTileTheme.merge(
|
child: ListTileTheme.merge(
|
||||||
visualDensity: const VisualDensity(vertical: -3),
|
visualDensity: PlatformDetector.isAutomotive() ? VisualDensity.standard : const VisualDensity(vertical: -3),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < children.length; i++) ...[
|
for (var i = 0; i < children.length; i++) ...[
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
///
|
///
|
||||||
/// Collapses to zero height wherever the bottom padding is already zero, so it
|
/// 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
|
/// 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`
|
/// Flutter's [Scaffold] has already stripped it because a `bottomNavigationBar`
|
||||||
/// is present.
|
/// is present.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user