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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user