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.
216 lines
8.1 KiB
Dart
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),
|
|
);
|
|
}
|