Files
plezy/lib/widgets/fitting_title_text.dart
T
edde746 c79ff68a7b 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.
2026-09-02 22:38:11 +02:00

139 lines
4.5 KiB
Dart

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;
final TextStyle? style;
final int maxLines;
final TextOverflow overflow;
final TextAlign? textAlign;
final AlignmentGeometry alignment;
final double minFontSize;
const FittingTitleText(
this.text, {
super.key,
this.style,
this.maxLines = 2,
this.overflow = TextOverflow.ellipsis,
this.textAlign,
this.alignment = Alignment.centerLeft,
this.minFontSize = 1,
});
@override
Widget build(BuildContext context) {
final baseStyle = style ?? DefaultTextStyle.of(context).style;
if (PlatformDetector.isAutomotive()) {
return Align(
alignment: alignment,
child: Text(text, style: baseStyle, maxLines: maxLines, overflow: overflow, textAlign: textAlign),
);
}
return LayoutBuilder(
builder: (context, constraints) {
var fittedStyle = baseStyle;
if (constraints.hasBoundedWidth &&
constraints.hasBoundedHeight &&
constraints.maxWidth > 0 &&
constraints.maxHeight > 0) {
fittedStyle = baseStyle.copyWith(
fontSize: _fitFontSize(
text: text,
style: baseStyle,
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr,
textScaler: MediaQuery.textScalerOf(context),
),
);
}
return Align(
alignment: alignment,
child: Text(text, style: fittedStyle, maxLines: maxLines, overflow: overflow, textAlign: textAlign),
);
},
);
}
/// 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,
required double maxWidth,
required double maxHeight,
required TextDirection textDirection,
required TextScaler textScaler,
}) {
final baseFontSize = style.fontSize ?? 14;
if (baseFontSize <= minFontSize) return baseFontSize;
final lineHeight = cachedSingleLineTextSize(
'X',
style: style,
textScaler: textScaler,
textDirection: textDirection,
).height;
if (maxLines * lineHeight <= maxHeight + 0.1) return baseFontSize;
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();
}
}
}