From c79ff68a7b4bcb990b5c209faaf3949049adc76d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:27:04 +0200 Subject: [PATCH] perf(tv): shape spotlight text once per swap instead of twice Every D-pad step on the TV home ends with a spotlight swap, and on a low-end box that frame ran over budget: 12 % of UI-thread CPU during navigation was paragraph layout, most of it shaping strings a second time to measure them before the render tree shaped them for real. - FittingTitleText no longer lays the title out to learn whether it fits: Text wraps and ellipsizes at maxLines, so the box can only overflow vertically, and one cached one-glyph line height settles that. Only a box shorter than maxLines lines still searches, now bracketed by the proportional shrink of the base layout and stopped at a 0.25 px tolerance instead of a fixed 12 bisections. - FittedMetadataLine and inlineRatingBadgeWidth measure through a small bounded cache keyed by text, style, scaler and direction, so separators, type labels, certifications, runtimes and years measure once per session. - MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their regex tokenisation per key; every card build and step re-asked them. Cold navigation on the Android 14 box (30 steps over three hubs): build 312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90 7.5-7.9 ms -> 6.8-7.2 ms. --- lib/media/media_hub.dart | 19 +++-- lib/utils/text_measure_cache.dart | 41 ++++++++++ lib/widgets/fitted_metadata_line.dart | 18 ++--- lib/widgets/fitting_title_text.dart | 92 ++++++++++++++++------- lib/widgets/media_rating_badge.dart | 15 ++-- test/widgets/fitting_title_text_test.dart | 70 +++++++++++++++++ 6 files changed, 201 insertions(+), 54 deletions(-) create mode 100644 lib/utils/text_measure_cache.dart create mode 100644 test/widgets/fitting_title_text_test.dart diff --git a/lib/media/media_hub.dart b/lib/media/media_hub.dart index 497300b64..a536779ea 100644 --- a/lib/media/media_hub.dart +++ b/lib/media/media_hub.dart @@ -75,24 +75,31 @@ class MediaHub { } } -bool _isContinueWatchingKey(String rawKey) { +// Per-key memo: every card build and D-pad step re-asks these, and the +// answer needs two regex passes over a key. The set of distinct hub keys a +// session sees is small, so the maps stay small too. +final Map _continueWatchingKeys = {}; +final Map _continueWatchingActionKeys = {}; +final RegExp _hubKeySeparators = RegExp(r'[^a-z0-9]+'); + +bool _isContinueWatchingKey(String rawKey) => _continueWatchingKeys.putIfAbsent(rawKey, () { final compactKey = _compactHubKey(rawKey); if (compactKey == 'continuewatching') return true; final tokens = _hubKeyTokens(rawKey); return tokens.contains('inprogress') || _hasTailToken(tokens, 'continue'); -} +}); -bool _usesContinueWatchingActionKey(String rawKey) { +bool _usesContinueWatchingActionKey(String rawKey) => _continueWatchingActionKeys.putIfAbsent(rawKey, () { final tokens = _hubKeyTokens(rawKey); return _hasTailToken(tokens, 'nextup') || tokens.contains('ondeck'); -} +}); List _hubKeyTokens(String rawKey) { - return rawKey.toLowerCase().split(RegExp(r'[^a-z0-9]+')).where((part) => part.isNotEmpty).toList(growable: false); + return rawKey.toLowerCase().split(_hubKeySeparators).where((part) => part.isNotEmpty).toList(growable: false); } -String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), ''); +String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(_hubKeySeparators, ''); bool _hasTailToken(List tokens, String token) => tokens.isNotEmpty && tokens.last == token; diff --git a/lib/utils/text_measure_cache.dart b/lib/utils/text_measure_cache.dart new file mode 100644 index 000000000..86aa20f8e --- /dev/null +++ b/lib/utils/text_measure_cache.dart @@ -0,0 +1,41 @@ +import 'dart:collection'; + +import 'package:flutter/painting.dart'; + +typedef _Key = (String, TextStyle, TextScaler, TextDirection); + +final LinkedHashMap<_Key, Size> _sizes = LinkedHashMap<_Key, Size>(); +const int _maxEntries = 512; + +/// Size of [text] laid out on one unconstrained line, memoized per +/// (text, style, scaler, direction). +/// +/// Metadata strips measure every field before building it so they can shed +/// parts that will not fit (#1893), and fitted titles need a line height to +/// know whether a box can overflow at all; each such measurement shapes a +/// string the render tree is about to shape again. Shaping dominates the TV +/// spotlight's info swap on low-end boxes, and the measured strings repeat +/// heavily — separators, "Movie", "TV-14", years, runtimes, "88%" — so a +/// small cache turns the measuring pass into hash lookups. Bounded and +/// insertion-ordered: the oldest entry goes first. +Size cachedSingleLineTextSize( + String text, { + required TextStyle style, + required TextScaler textScaler, + required TextDirection textDirection, +}) { + final key = (text, style, textScaler, textDirection); + final cached = _sizes[key]; + if (cached != null) return cached; + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: textDirection, + textScaler: textScaler, + maxLines: 1, + )..layout(); + final size = painter.size; + painter.dispose(); + if (_sizes.length >= _maxEntries) _sizes.remove(_sizes.keys.first); + _sizes[key] = size; + return size; +} diff --git a/lib/widgets/fitted_metadata_line.dart b/lib/widgets/fitted_metadata_line.dart index ed90bdf17..c26a3404e 100644 --- a/lib/widgets/fitted_metadata_line.dart +++ b/lib/widgets/fitted_metadata_line.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../media/media_rating.dart'; +import '../utils/text_measure_cache.dart'; import 'media_rating_badge.dart'; /// One slot on a [FittedMetadataLine]. @@ -77,17 +78,12 @@ class FittedMetadataLine extends StatelessWidget { final badgeGap = ratingSpacing ?? 4; final entryGap = ratingEntrySpacing ?? 10; - double textWidth(String text) { - final painter = TextPainter( - text: TextSpan(text: text, style: effectiveStyle), - textDirection: textDirection, - textScaler: textScaler, - maxLines: 1, - )..layout(); - final width = painter.width; - painter.dispose(); - return width; - } + double textWidth(String text) => cachedSingleLineTextSize( + text, + style: effectiveStyle, + textScaler: textScaler, + textDirection: textDirection, + ).width; // Units are the droppable atoms: a text part is one unit, a ratings // slot is one unit per badge. diff --git a/lib/widgets/fitting_title_text.dart b/lib/widgets/fitting_title_text.dart index d05b3fff0..929a2f7ab 100644 --- a/lib/widgets/fitting_title_text.dart +++ b/lib/widgets/fitting_title_text.dart @@ -1,5 +1,8 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../utils/platform_detector.dart'; +import '../utils/text_measure_cache.dart'; class FittingTitleText extends StatelessWidget { final String text; @@ -57,6 +60,22 @@ class FittingTitleText extends StatelessWidget { ); } + /// Sizes that differ by less than this are indistinguishable on screen, so + /// the bisection stops there instead of running a fixed iteration count. + static const double _fontSizeTolerance = 0.25; + + /// The largest font size, down to [minFontSize], at which [text] fits the + /// box. + /// + /// The box can only overflow vertically: [Text] wraps and ellipsizes at + /// [maxLines], so a title fits whenever [maxLines] lines of the base style + /// do. That is settled from one cached one-glyph line height, without + /// shaping the title — which the render tree is about to do anyway and + /// which is the single most expensive thing a TV spotlight swap does on a + /// low-end box. Only a box shorter than [maxLines] lines needs the search, + /// and it is bracketed: layout height scales with the font size at a fixed + /// line count, so the proportional shrink of the base layout is a fitting + /// lower bound and the bisection only refines between it and the base size. double _fitFontSize({ required String text, required TextStyle style, @@ -68,37 +87,52 @@ class FittingTitleText extends StatelessWidget { final baseFontSize = style.fontSize ?? 14; if (baseFontSize <= minFontSize) return baseFontSize; - bool fits(double fontSize) { - final painter = TextPainter( - text: TextSpan( - text: text, - style: style.copyWith(fontSize: fontSize), - ), - maxLines: maxLines, - ellipsis: overflow == TextOverflow.ellipsis ? '\u2026' : null, - textDirection: textDirection, - textScaler: textScaler, - textAlign: textAlign ?? TextAlign.start, - )..layout(maxWidth: maxWidth); - final result = painter.height <= maxHeight + 0.1 && painter.width <= maxWidth + 0.1; - painter.dispose(); - return result; - } + final lineHeight = cachedSingleLineTextSize( + 'X', + style: style, + textScaler: textScaler, + textDirection: textDirection, + ).height; + if (maxLines * lineHeight <= maxHeight + 0.1) return baseFontSize; - if (fits(baseFontSize)) return baseFontSize; - - if (!fits(minFontSize)) return minFontSize; - - var low = minFontSize; - var high = baseFontSize; - for (var i = 0; i < 12; i++) { - final mid = (low + high) / 2; - if (fits(mid)) { - low = mid; - } else { - high = mid; + final painter = TextPainter( + maxLines: maxLines, + ellipsis: overflow == TextOverflow.ellipsis ? '\u2026' : null, + textDirection: textDirection, + textScaler: textScaler, + textAlign: textAlign ?? TextAlign.start, + ); + try { + bool fits(double fontSize) { + painter + ..text = TextSpan( + text: text, + style: style.copyWith(fontSize: fontSize), + ) + ..layout(maxWidth: maxWidth); + return painter.height <= maxHeight + 0.1 && painter.width <= maxWidth + 0.1; } + + if (fits(baseFontSize)) return baseFontSize; + final shrink = math.min(maxHeight / painter.height, maxWidth / painter.width); + + var low = math.max(minFontSize, baseFontSize * shrink); + if (low <= minFontSize || !fits(low)) { + if (!fits(minFontSize)) return minFontSize; + low = minFontSize; + } + var high = baseFontSize; + while (high - low > _fontSizeTolerance) { + final mid = (low + high) / 2; + if (fits(mid)) { + low = mid; + } else { + high = mid; + } + } + return low; + } finally { + painter.dispose(); } - return low; } } diff --git a/lib/widgets/media_rating_badge.dart b/lib/widgets/media_rating_badge.dart index 568fc587a..d03295cb2 100644 --- a/lib/widgets/media_rating_badge.dart +++ b/lib/widgets/media_rating_badge.dart @@ -6,6 +6,7 @@ import '../media/media_item.dart'; import '../media/media_rating.dart'; import '../utils/formatters.dart'; import '../utils/rating_utils.dart'; +import '../utils/text_measure_cache.dart'; import 'app_icon.dart'; /// Every attributed score for [item], falling back to [fallbackItem] as a @@ -57,15 +58,13 @@ double inlineRatingBadgeWidth( }) { final size = iconSize ?? textStyle.fontSize ?? 13; final info = ratingInfoForSource(rating.source, rating.value); - final painter = TextPainter( - text: TextSpan(text: mediaRatingLabel(rating), style: textStyle), - textDirection: textDirection, + final labelWidth = cachedSingleLineTextSize( + mediaRatingLabel(rating), + style: textStyle, textScaler: textScaler, - maxLines: 1, - )..layout(); - final width = size * (info?.iconAspect ?? 1) + (spacing ?? 4) + painter.width; - painter.dispose(); - return width; + textDirection: textDirection, + ).width; + return size * (info?.iconAspect ?? 1) + (spacing ?? 4) + labelWidth; } /// The bare badge row for an explicit list of scores, for single-line diff --git a/test/widgets/fitting_title_text_test.dart b/test/widgets/fitting_title_text_test.dart new file mode 100644 index 000000000..03673701c --- /dev/null +++ b/test/widgets/fitting_title_text_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/fitting_title_text.dart'; + +const _style = TextStyle(fontSize: 40, letterSpacing: 0, wordSpacing: 0); + +Future _fittedSize(WidgetTester tester, String text, {required double width, required double height}) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: width, + height: height, + child: FittingTitleText(text, style: _style), + ), + ), + ), + ); + return tester.widget(find.text(text)).style!.fontSize!; +} + +/// Whether [text] at [fontSize], wrapped to two lines like the widget does, +/// fits [height] — the widget's own definition of fitting. +bool _fits(String text, double fontSize, {required double width, required double height}) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: _style.copyWith(fontSize: fontSize), + ), + maxLines: 2, + ellipsis: '\u2026', + textDirection: TextDirection.ltr, + )..layout(maxWidth: width); + final fits = painter.height <= height + 0.1; + painter.dispose(); + return fits; +} + +void main() { + testWidgets('keeps the base size when two lines fit the box', (tester) async { + expect(await _fittedSize(tester, 'ABCDE', width: 400, height: 100), 40); + // Width never shrinks the title: a too-long line is ellipsized instead. + expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 50, height: 100), 40); + }); + + testWidgets('a box shorter than two lines gets the largest size that still fits', (tester) async { + for (final (text, width, height) in [ + ('ABCDE FGHIJ', 50.0, 8.0), // one line beats two: 11 chars on one line at ~4.5 > two lines at 4 + ('ABCDE FGHIJ', 50.0, 20.0), // two lines at ~10, bounded by the line width + ('The Lord of the Rings: The Return of the King', 300.0, 60.0), + ]) { + final size = await _fittedSize(tester, text, width: width, height: height); + expect(size, lessThan(40), reason: '$text in ${width}x$height'); + expect( + _fits(text, size, width: width, height: height), + isTrue, + reason: '$text at $size must fit', + ); + expect( + _fits(text, size + 0.5, width: width, height: height), + isFalse, + reason: '$text at $size is not maximal', + ); + } + }); + + testWidgets('never goes below the minimum size', (tester) async { + expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 5, height: 0.5), 1); + }); +}