fix(tv): fit fallback logo titles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user