Files
plezy/lib/theme/mono_theme.dart
edde746 cb55134cef fix(startup): move optional work off the launch gate
Cold start on the target TV box reaches its first frame 21 ms after `dart_main`,
so nothing Dart-side gates the splash. Everything below is on the path to the
first *useful* frame, which is a strictly serial chain and where the viewer
actually waits.

- `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an
  applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole
  `ThemeData` again. It was rebuilt five times per cold start, two of them before
  `runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by
  palette plus `TargetPlatform` -- the platform matters because `ThemeData()`
  derives tap target size, visual density and typography from
  `defaultTargetPlatform`, so a palette-only key would be wrong under a debug or
  test platform override.
- `initializeDateFormatting` ignores its locale argument and builds CLDR symbols
  and patterns for all 121 locales synchronously. It blocked the gate ahead of the
  database open for data that only content screens use.
- `DownloadStorageService.initialize` ended in a `path_provider` round trip plus
  mkdir at the tail of the gate, contradicting the comment above it that already
  explained offline artwork is not a launch requirement.
- `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from
  `initState` of the widget whose first build produces the first app frame, and
  the RSS watchdog installed a periodic timer there whose first useful sample is
  15 s away regardless.
- `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main
  isolate, uncached, on every registry read and on every Drift re-emit -- and the
  binder writes tokens during the startup sweep, so writes re-triggered reads.
  Decryption is memoized by ciphertext, with `invalidateCache()` wired into the
  preference-store repair path so a repaired install cannot serve stale plaintext.
- `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around
  the legacy migration are deliberately not merged; only genuinely concurrent
  callers share a snapshot.
- `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit
  purely to compare, allocating two maps and two strings each time.
- The splash rendered one `CircularProgressIndicator` per pending server on top of
  the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of
  `awaitBindingSettle` -- competing with the startup work they were reporting on.

Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms,
`credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is
unchanged at ~18 ms, as expected.
2026-08-23 18:13:39 +02:00

216 lines
8.1 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'gapped_track_shape.dart';
import 'mono_tokens.dart';
final Map<({bool dark, bool oled, TargetPlatform platform}), ThemeData> _monoThemeCache = {};
ThemeData monoTheme({required bool dark, bool oled = false}) {
// ThemeData derives several defaults from defaultTargetPlatform.
final key = (dark: dark || oled, oled: oled, platform: defaultTargetPlatform);
final cached = _monoThemeCache[key];
if (cached != null) return cached;
final theme = _buildMonoTheme(dark: key.dark, oled: key.oled, platform: key.platform);
_monoThemeCache[key] = theme;
return theme;
}
ThemeData _buildMonoTheme({required bool dark, required bool oled, required TargetPlatform platform}) {
// neutral greys tuned for crisp contrast
final ({Color bg, Color surface, Color outline, Color text, Color textMuted}) c;
if (oled) {
c = (
bg: const Color(0xFF000000), // Pure black for OLED
surface: const Color(0xFF0A0A0A), // Very dark gray
outline: const Color(0x1FFFFFFF),
text: const Color(0xFFEDEDED),
textMuted: const Color(0x99EDEDED),
);
} else if (dark) {
c = (
bg: const Color(0xFF0E0F12),
surface: const Color(0xFF15171C),
outline: const Color(0x1FFFFFFF),
text: const Color(0xFFEDEDED),
textMuted: const Color(0x99EDEDED),
);
} else {
c = (
bg: const Color(0xFFF7F7F8),
surface: const Color(0xFFFFFFFF),
outline: const Color(0x19000000),
text: const Color(0xFF111111),
textMuted: const Color(0x99111111),
);
}
final isDark = dark || oled;
final clickableCursor = WidgetStateProperty.resolveWith<MouseCursor>(
(states) => states.contains(WidgetState.disabled) ? MouseCursor.defer : SystemMouseCursors.click,
);
final buttonStyle = ButtonStyle(
mouseCursor: clickableCursor,
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 18, vertical: 14)),
elevation: const WidgetStatePropertyAll(0),
backgroundColor: WidgetStatePropertyAll(c.text),
foregroundColor: WidgetStatePropertyAll(isDark ? c.bg : Colors.white),
shape: const WidgetStatePropertyAll(StadiumBorder()),
);
final base = ThemeData(
platform: platform,
useMaterial3: true,
brightness: isDark ? Brightness.dark : Brightness.light,
colorScheme: ColorScheme(
brightness: isDark ? Brightness.dark : Brightness.light,
primary: c.text,
onPrimary: isDark ? c.bg : Colors.white,
secondary: c.text,
onSecondary: c.bg,
surface: c.surface,
onSurface: c.text,
error: const Color(0xFFB00020),
onError: Colors.white,
tertiary: c.text,
onTertiary: c.bg,
primaryContainer: c.surface,
onPrimaryContainer: c.text,
secondaryContainer: c.surface,
onSecondaryContainer: c.text,
surfaceContainerHighest: c.surface,
surfaceContainerLow: c.bg,
surfaceDim: c.bg,
surfaceBright: c.surface,
outline: c.outline,
shadow: Colors.transparent,
scrim: Colors.black,
inverseSurface: c.text,
onInverseSurface: c.bg,
inversePrimary: c.bg,
),
// remove "Material feel"
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
// Explicit mono-derived tile highlights: ListTile's native focus/hover
// fill is the dpad focus visual inside M3E grouped-list cards.
focusColor: c.text.withValues(alpha: 0.12),
hoverColor: c.text.withValues(alpha: 0.05),
dividerColor: c.outline,
scaffoldBackgroundColor: c.bg,
appBarTheme: AppBarTheme(
backgroundColor: c.bg,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
foregroundColor: c.text,
titleTextStyle: TextStyle(color: c.text, fontSize: 18, fontWeight: .w700, letterSpacing: -0.2),
),
textTheme: Typography.englishLike2021
.apply(bodyColor: c.text, displayColor: c.text)
.copyWith(
displayLarge: const TextStyle(fontWeight: .w700, letterSpacing: -0.5),
titleMedium: const TextStyle(fontWeight: .w600),
bodyMedium: TextStyle(color: c.text),
bodySmall: TextStyle(color: c.textMuted),
),
cardTheme: CardThemeData(
color: c.surface,
elevation: 0,
margin: .zero,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))),
),
inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted),
elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle),
filledButtonTheme: FilledButtonThemeData(style: buttonStyle),
textButtonTheme: TextButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)),
outlinedButtonTheme: OutlinedButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)),
iconButtonTheme: IconButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)),
sliderTheme: SliderThemeData(
// The mono scheme maps surfaceContainerHighest (the M3 default inactive
// track) to the same color as surface cards, which makes the inactive
// track invisible inside grouped-list items.
inactiveTrackColor: c.text.withValues(alpha: 0.12),
trackHeight: 16,
trackGap: 6,
thumbSize: const WidgetStatePropertyAll(Size(4, 20)),
thumbShape: const HandleThumbShape(),
trackShape: const GappedTrackShape(),
tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 2),
// ignore: deprecated_member_use — opting into the 2024 slider appearance until the default flips
year2023: false,
),
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
listTileTheme: ListTileThemeData(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
iconColor: c.text,
textColor: c.text,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: c.bg,
elevation: 0,
indicatorColor: Colors.transparent,
labelTextStyle: WidgetStatePropertyAll(TextStyle(color: c.textMuted, fontSize: 11)),
iconTheme: WidgetStateProperty.resolveWith((states) {
final active = states.contains(WidgetState.selected);
return IconThemeData(opacity: active ? 1 : 0.6, size: 22, color: c.text);
}),
),
// Floating snackbars auto-offset above the Scaffold's bottom NavigationBar,
// so they don't cover it on mobile. Background color tracks the theme to
// avoid jarring brightness on HDR playback / dark mode.
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: c.surface,
contentTextStyle: TextStyle(color: c.text),
actionTextColor: c.text,
elevation: 6,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
insetPadding: const EdgeInsets.all(16),
),
);
return base.copyWith(
extensions: [
MonoTokens(
radiusSm: 8,
radiusMd: 12,
radiusLg: 20,
radiusXs: 5,
groupGap: 2,
space: 12,
fast: const Duration(milliseconds: 120),
normal: const Duration(milliseconds: 200),
slow: const Duration(milliseconds: 300),
expressive: const Duration(milliseconds: 350),
bg: c.bg,
surface: c.surface,
outline: c.outline,
text: c.text,
textMuted: c.textMuted,
),
],
);
}
/// Brighter fill on focus so input focus is visible inside TV overscan.
InputDecorationTheme _inputDecorationTheme(Color text, Color textMuted) {
final unfocusedFill = text.withValues(alpha: 0.08);
final focusedFill = text.withValues(alpha: 0.18);
const border = OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide.none);
return InputDecorationTheme(
filled: true,
fillColor: WidgetStateColor.resolveWith(
(states) => states.contains(WidgetState.focused) ? focusedFill : unfocusedFill,
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: border,
enabledBorder: border,
focusedBorder: border,
hintStyle: TextStyle(color: textMuted),
);
}