fix(tv): fit fallback logo titles

This commit is contained in:
edde746
2026-05-22 13:34:30 +02:00
parent fc231a9e72
commit 636ecc9d3a
5 changed files with 173 additions and 56 deletions
+26 -37
View File
@@ -41,6 +41,7 @@ import '../providers/user_profile_provider.dart';
import '../services/storage_service.dart';
import '../services/settings_service.dart';
import '../widgets/settings_builder.dart';
import '../widgets/fitting_title_text.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
import '../mixins/refreshable.dart';
@@ -1775,6 +1776,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final alignLeft = isTv || isLargeScreen;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final heroLogoWidth = isTv ? TvLayoutConstants.heroLogoWidth : 400.0;
final heroLogoHeight = isTv ? TvLayoutConstants.heroLogoHeight : 120.0;
final heroTitleStyle = theme.textTheme.displaySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: isTv ? 52 : null,
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
);
// Determine content type label for chip
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
@@ -1924,16 +1933,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Show logo or name/title
if (heroItem.clearLogoPath != null)
SizedBox(
height: isTv ? TvLayoutConstants.heroLogoHeight : 120,
width: isTv ? TvLayoutConstants.heroLogoWidth : 400,
height: heroLogoHeight,
width: heroLogoWidth,
child: Builder(
builder: (context) {
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final logoUrl = MediaImageHelper.getOptimizedImageUrl(
client: heroClient,
thumbPath: heroItem.clearLogoPath,
maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400,
maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120,
maxWidth: heroLogoWidth,
maxHeight: heroLogoHeight,
devicePixelRatio: dpr,
imageType: ImageType.logo,
);
@@ -1944,34 +1953,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
cacheManager: PlexImageCacheManager.instance,
filterQuality: FilterQuality.medium,
fit: BoxFit.contain,
memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr)
.clamp(200, isTv ? 1000 : 800)
.round(),
memCacheWidth: (heroLogoWidth * dpr).clamp(200, isTv ? 1000 : 800).round(),
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
placeholder: (context, url) => const SizedBox.shrink(),
errorBuilder: (context, error, stackTrace) {
// Fallback to text if logo fails to load
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Align(
return FittingTitleText(
showName,
style: heroTitleStyle,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
child: Text(
showName,
style: theme.textTheme.displaySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: isTv ? 52 : null,
shadows: [
Shadow(
color: colorScheme.surface.withValues(alpha: 0.8),
blurRadius: 8,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
),
);
},
),
@@ -1982,17 +1973,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
)
else
Text(
showName,
style: theme.textTheme.displaySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: isTv ? 52 : null,
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
SizedBox(
height: heroLogoHeight,
width: heroLogoWidth,
child: FittingTitleText(
showName,
style: heroTitleStyle,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
),
// Metadata as dot-separated text with content type
+8 -13
View File
@@ -68,6 +68,7 @@ import '../mixins/server_bound_media_mixin.dart';
import '../utils/watch_state_notifier.dart';
import '../utils/deletion_notifier.dart';
import '../widgets/episode_card.dart';
import '../widgets/fitting_title_text.dart';
import 'actor_media_screen.dart';
import '../widgets/focusable_tab_chip.dart';
import '../widgets/hub_section.dart';
@@ -846,20 +847,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
FontWeight fontWeight = FontWeight.bold,
double shadowBlur = 8,
}) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: fontWeight,
fontSize: fontSize,
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
final baseStyle = (Theme.of(context).textTheme.displaySmall ?? const TextStyle()).copyWith(
color: Colors.white,
fontWeight: fontWeight,
fontSize: fontSize,
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)],
);
return FittingTitleText(title, style: baseStyle);
}
/// Build radial progress indicator for download button
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.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;
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),
);
},
);
}
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;
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;
}
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;
}
}
return low;
}
}
+7 -6
View File
@@ -12,6 +12,7 @@ import '../utils/formatters.dart';
import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart';
import 'app_icon.dart';
import 'fitting_title_text.dart';
import 'optimized_media_image.dart' show blurArtwork;
class TvSpotlightBackground extends StatelessWidget {
@@ -203,11 +204,13 @@ class TvSpotlightBackground extends StatelessWidget {
Widget _buildLogoOrTitle(BuildContext context, MediaItem media, String title) {
final scale = _scale(context);
final logoPath = media.clearLogoPath;
if (logoPath == null || logoPath.isEmpty) return _buildTitle(context, title);
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final logoWidth = _logoWidth(scale);
final logoHeight = _logoHeight(scale);
if (logoPath == null || logoPath.isEmpty) {
return SizedBox(width: logoWidth, height: logoHeight, child: _buildTitle(context, title));
}
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: logoPath,
@@ -239,10 +242,8 @@ class TvSpotlightBackground extends StatelessWidget {
Widget _buildTitle(BuildContext context, String title) {
final scale = _scale(context);
return Text(
return FittingTitleText(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontSize: _titleFontSize(scale),
@@ -15,6 +15,7 @@ import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/layout_constants.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
@@ -35,6 +36,40 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('TV detail scales fallback title to fit logo bounds', (tester) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(800, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const title = 'The Surprisingly Long Movie Title That Needs Two Whole Lines';
final movie = MediaItem(
id: 'movie_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: title,
summary: 'A compact viewport should make the fallback title shrink before it can overlap the detail text.',
);
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MediaDetailScreen(metadata: movie),
),
),
);
await tester.pump();
await tester.pump();
final titleText = tester.widget<Text>(find.text(title));
final baseFontSize = 56 * TvLayoutConstants.scaleForSize(const Size(800, 480));
expect(titleText.style?.fontSize, isNotNull);
expect(titleText.style!.fontSize!, lessThan(baseFontSize));
});
testWidgets('TV detail reveals selected season before remaining episode caches load', (tester) async {
await SettingsService.getInstance();