From 636ecc9d3a88e779b7db859c7d4c0707e1769783 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 22 May 2026 13:34:30 +0200 Subject: [PATCH] fix(tv): fit fallback logo titles --- lib/screens/discover_screen.dart | 63 ++++++-------- lib/screens/media_detail_screen.dart | 21 ++--- lib/widgets/fitting_title_text.dart | 97 ++++++++++++++++++++++ lib/widgets/tv_spotlight_background.dart | 13 +-- test/screens/media_detail_screen_test.dart | 35 ++++++++ 5 files changed, 173 insertions(+), 56 deletions(-) create mode 100644 lib/widgets/fitting_title_text.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index f41e50a7..e81865a7 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 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 // 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 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 ), ) 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 diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index d8019f30..3cb88274 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 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 diff --git a/lib/widgets/fitting_title_text.dart b/lib/widgets/fitting_title_text.dart new file mode 100644 index 00000000..d71ee290 --- /dev/null +++ b/lib/widgets/fitting_title_text.dart @@ -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; + } +} diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 4aad1497..5787b93d 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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), diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index b7d7c414..a7f27149 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -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(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();