diff --git a/lib/main.dart b/lib/main.dart index 9e965c91..ab0302ca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -147,12 +147,23 @@ Future main() async { } Future _bootstrapApp() async { + final startupWatch = Stopwatch()..start(); + var lastStartupMarkMs = 0; + void markStartupPhase(String phase) { + if (!kProfileMode) return; + final elapsedMs = startupWatch.elapsedMilliseconds; + appLogger.i('Startup phase $phase: ${elapsedMs - lastStartupMarkMs}ms (total ${elapsedMs}ms)'); + lastStartupMarkMs = elapsedMs; + } + final settings = await SettingsService.getInstance(); + markStartupPhase('settings'); final savedLocale = settings.read(SettingsService.appLocale); unawaited(LocaleSettings.setLocale(savedLocale)); await initializeDateFormatting(savedLocale.languageCode, null); + markStartupPhase('locale'); // One-time cleanup of the old flutter_cache_manager image cache directory // (replaced by cached_network_image_ce in a prior refactor). @@ -197,6 +208,7 @@ Future _bootstrapApp() async { await Future.wait(futures); final storage = await storageFuture; + markStartupPhase('platform-services'); // Configure image cache — keep budget modest to leave headroom for Skia // decode buffers. Runs after the futures so the effects tier is resolved. @@ -229,8 +241,10 @@ Future _bootstrapApp() async { // `adb shell dumpsys meminfo` when tuning them. appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB'); } + markStartupPhase('environment'); await DownloadStorageService.instance.initialize(settings); + markStartupPhase('download-storage'); FullscreenStateManager().startMonitoring(); @@ -253,6 +267,7 @@ Future _bootstrapApp() async { } await TraktScrobbleService.instance.initialize(); + markStartupPhase('trakt-scrobble'); _registerShaderLicenses(); @@ -263,6 +278,7 @@ Future _bootstrapApp() async { return const ColoredBox(color: Color(0xFF000000)); }; + markStartupPhase('pre-runApp'); runApp(MainApp(settings: settings, storage: storage)); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 7407a708..d177d1aa 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -95,13 +95,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun @override void initState() { super.initState(); - _focusTracker = FocusMemoryTracker( - onFocusChanged: () { - // ignore: no-empty-block - setState triggers rebuild to update focus styling - setStateIfMounted(() {}); - }, - debugLabelPrefix: 'settings', - ); + _focusTracker = FocusMemoryTracker(debugLabelPrefix: 'settings'); if (_keyboardShortcutsSupported) { KeyboardShortcutsService.getInstance().then((s) { setStateIfMounted(() => _keyboardService = s); diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index c06b0541..cd248465 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -11,9 +11,16 @@ import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import '../utils/media_server_http_client.dart'; +import 'device_performance.dart'; final _artworkHttpClient = MediaServerHttpClient(usePlexApiClient: true); -final _artworkRequestLimiter = _RequestLimiter(6); + +@visibleForTesting +int artworkRequestConcurrencyForTier({required bool reduced}) => reduced ? 3 : 6; + +// Top-level fields are initialized lazily. The first artwork request happens +// after DevicePerformance has resolved the hardware tier during bootstrap. +final _artworkRequestLimiter = _RequestLimiter(artworkRequestConcurrencyForTier(reduced: DevicePerformance.isReduced)); Future closeArtworkHttpClientGracefully({Duration drainTimeout = const Duration(seconds: 5)}) { return _artworkHttpClient.closeGracefully(drainTimeout: drainTimeout); diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index fe3b4953..ff63863d 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -6,9 +6,9 @@ import 'package:material_symbols_icons/symbols.dart'; import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; -import '../mixins/mounted_set_state_mixin.dart'; import '../utils/platform_detector.dart'; import 'clickable_cursor.dart'; +import 'listenable_selector.dart'; bool _keyboardTextWarmedUp = false; @@ -126,6 +126,8 @@ class _TvKey { const _TvKey.action(this.label, this.type, {this.icon}) : value = ''; } +typedef _TvKeyboardSelection = ({int row, int column}); + class _TvVirtualKeyboardDialog extends StatefulWidget { final TextEditingController controller; final String? hintText; @@ -157,35 +159,48 @@ class _TvVirtualKeyboardDialog extends StatefulWidget { State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState(); } -class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with MountedSetStateMixin { +class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { final _focusNode = FocusNode(debugLabel: 'TvVirtualKeyboard'); - int _row = 0; - int _column = 0; + final ValueNotifier<_TvKeyboardSelection> _selection = ValueNotifier((row: 0, column: 0)); + List> _rows = const []; + Locale? _rowsLocale; bool _shiftEnabled = false; bool _symbolsPage = false; - List> get _rows => _symbolsPage ? _buildSymbolRows() : _buildMainRows(); + int get _row => _selection.value.row; + int get _column => _selection.value.column; int get _gridColumnCount => _isNumberKeyboard ? 3 : 12; @override void initState() { super.initState(); - _column = _firstFocusableColumn(_row) ?? 0; - widget.controller.addListener(_handleTextChanged); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNode.requestFocus(); }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final locale = Localizations.maybeLocaleOf(context); + if (_rows.isNotEmpty && locale == _rowsLocale) return; + _rowsLocale = locale; + _refreshRows(resetSelection: _rows.isEmpty); + } + @override void dispose() { - widget.controller.removeListener(_handleTextChanged); + _selection.dispose(); _focusNode.dispose(); super.dispose(); } - void _handleTextChanged() { - setStateIfMounted(() {}); + void _refreshRows({bool resetSelection = false}) { + _rows = _symbolsPage ? _buildSymbolRows() : _buildMainRows(); + final current = _selection.value; + final row = resetSelection ? 0 : current.row.clamp(0, _rows.length - 1); + final column = resetSelection ? (_firstFocusableColumn(row) ?? 0) : _nearestFocusableColumn(row, current.column); + _selection.value = (row: row, column: column); } bool get _isNumberKeyboard { @@ -431,19 +446,14 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with } if (nextColumn == null) return; final column = nextColumn; - setState(() { - _column = column; - }); + _selection.value = (row: _row, column: column); } void _moveVertical(int delta) { final rows = _rows; final nextRow = (_row + delta) % rows.length; final nextColumn = _nearestFocusableColumn(nextRow, _column); - setState(() { - _row = nextRow; - _column = nextColumn; - }); + _selection.value = (row: nextRow, column: nextColumn); } int? _firstFocusableColumn(int row) { @@ -488,7 +498,10 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with _insert(key.value); return; case _TvKeyType.shift: - setState(() => _shiftEnabled = !_shiftEnabled); + setState(() { + _shiftEnabled = !_shiftEnabled; + _refreshRows(); + }); return; case _TvKeyType.symbols: _toggleSymbolsPage(); @@ -517,9 +530,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with void _toggleSymbolsPage() { setState(() { _symbolsPage = !_symbolsPage; - final rows = _rows; - _row = _row.clamp(0, rows.length - 1).toInt(); - _column = _nearestFocusableColumn(_row, _column); + _refreshRows(); }); } @@ -633,7 +644,6 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with final media = MediaQuery.of(context); final metrics = _metricsFor(media.size); final colorScheme = Theme.of(context).colorScheme; - final text = widget.controller.text; return Dialog( key: const Key('tv_virtual_keyboard_dialog'), @@ -663,7 +673,10 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with mainAxisSize: .min, crossAxisAlignment: .stretch, children: [ - _buildPreview(context, text, metrics), + ValueListenableBuilder( + valueListenable: widget.controller, + builder: (context, value, _) => _buildPreview(context, value.text, metrics), + ), SizedBox(height: metrics.previewGap), for (var row = 0; row < _rows.length; row++) ...[ _buildRow(context, row, metrics), @@ -757,41 +770,43 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with return SizedBox(width: metrics.keySize, height: metrics.keySize); } - final colorScheme = Theme.of(context).colorScheme; - final selected = row == _row && column == _column; - final active = key.type == _TvKeyType.shift && _shiftEnabled; - final background = selected - ? colorScheme.primary - : active - ? colorScheme.secondaryContainer - : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88); - final foreground = selected - ? colorScheme.onPrimary - : active - ? colorScheme.onSecondaryContainer - : colorScheme.onSurface; + return ListenableSelector( + listenable: _selection, + selector: () => _selection.value == (row: row, column: column), + builder: (context, selected, _) { + final colorScheme = Theme.of(context).colorScheme; + final active = key.type == _TvKeyType.shift && _shiftEnabled; + final background = selected + ? colorScheme.primary + : active + ? colorScheme.secondaryContainer + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88); + final foreground = selected + ? colorScheme.onPrimary + : active + ? colorScheme.onSecondaryContainer + : colorScheme.onSurface; - return ClickableCursor( - child: GestureDetector( - onTap: () { - setState(() { - _row = row; - _column = column; - }); - _activate(key); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - width: metrics.keySize, - height: metrics.keySize, - alignment: .center, - decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(metrics.keyRadius)), - child: Padding( - padding: .symmetric(horizontal: metrics.keySize * 0.04), - child: _buildKeyContent(context, key, foreground, metrics), + return ClickableCursor( + child: GestureDetector( + onTap: () { + _selection.value = (row: row, column: column); + _activate(key); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: metrics.keySize, + height: metrics.keySize, + alignment: .center, + decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(metrics.keyRadius)), + child: Padding( + padding: .symmetric(horizontal: metrics.keySize * 0.04), + child: _buildKeyContent(context, key, foreground, metrics), + ), + ), ), - ), - ), + ); + }, ); } diff --git a/test/focus/focus_memory_tracker_test.dart b/test/focus/focus_memory_tracker_test.dart new file mode 100644 index 00000000..5c5c6d72 --- /dev/null +++ b/test/focus/focus_memory_tracker_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focus_memory_tracker.dart'; + +void main() { + testWidgets('tracks and restores focus without a rebuild callback', (tester) async { + final tracker = FocusMemoryTracker(debugLabelPrefix: 'test'); + addTearDown(tracker.dispose); + final first = tracker.get('first'); + final second = tracker.get('second'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Focus(focusNode: first, child: const SizedBox(width: 10, height: 10)), + Focus(focusNode: second, child: const SizedBox(width: 10, height: 10)), + ], + ), + ), + ), + ); + + first.requestFocus(); + await tester.pump(); + expect(tracker.lastFocusedKey, 'first'); + expect(tracker.isFocused('first'), isTrue); + + second.requestFocus(); + await tester.pump(); + expect(tracker.lastFocusedKey, 'second'); + expect(tracker.isFocused('first'), isFalse); + expect(tracker.isFocused('second'), isTrue); + + first.requestFocus(); + await tester.pump(); + second.unfocus(); + expect(tracker.restoreFocus(), isTrue); + await tester.pump(); + expect(first.hasFocus, isTrue); + }); + + testWidgets('uses the fallback key before any item has been focused', (tester) async { + final tracker = FocusMemoryTracker(debugLabelPrefix: 'test'); + addTearDown(tracker.dispose); + final fallback = tracker.get('fallback'); + + await tester.pumpWidget( + MaterialApp( + home: Focus(focusNode: fallback, child: const SizedBox(width: 10, height: 10)), + ), + ); + + expect(tracker.restoreFocus(fallbackKey: 'fallback'), isTrue); + await tester.pump(); + expect(fallback.hasFocus, isTrue); + expect(tracker.lastFocusedKey, 'fallback'); + }); +} diff --git a/test/services/image_cache_service_test.dart b/test/services/image_cache_service_test.dart index d2525158..f8f57d03 100644 --- a/test/services/image_cache_service_test.dart +++ b/test/services/image_cache_service_test.dart @@ -12,6 +12,11 @@ import 'package:plezy/services/image_cache_service.dart'; void main() { const timeout = Duration(seconds: 5); + test('reduced-tier devices cap artwork fan-out', () { + expect(artworkRequestConcurrencyForTier(reduced: true), 3); + expect(artworkRequestConcurrencyForTier(reduced: false), 6); + }); + http.Request get(String path) => http.Request('GET', Uri.parse('https://example.invalid$path')); MockClient mixedClient() => MockClient((request) async { diff --git a/test/widgets/tv_virtual_keyboard_test.dart b/test/widgets/tv_virtual_keyboard_test.dart index da179d87..750a73b1 100644 --- a/test/widgets/tv_virtual_keyboard_test.dart +++ b/test/widgets/tv_virtual_keyboard_test.dart @@ -62,6 +62,38 @@ void main() { expect(find.byType(Dialog), findsOneWidget); }); + testWidgets('directional navigation selects the expected key and wraps around spacers', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await _pumpKeyboard(tester, controller: controller); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + expect(controller.text, '2'); + + // Re-open at the first key, then wrap left past the leading spacer to 0. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await _pumpKeyboard(tester, controller: controller); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + expect(controller.text, '20'); + }); + + testWidgets('external controller changes rebuild the preview', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await _pumpKeyboard(tester, controller: controller); + controller.text = 'updated externally'; + await tester.pump(); + + expect(find.text('updated externally'), findsOneWidget); + }); + testWidgets('keyboard enter inserts newline for multiline input', (tester) async { final controller = TextEditingController(text: 'a'); addTearDown(controller.dispose);