From 976bdbd021e22d8386603a78b7eec09ea4edd628 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:18:44 +0200 Subject: [PATCH] fix(ui): keep hero titles and clear logos legible in light theme On the light theme, the detail hero's fallback title was hard-coded white over a scrim that washes artwork toward the near-white background, and white-on-transparent clear logos vanished into the same wash on the detail, home, and TV spotlight heroes. The title fallback now defaults to the theme foreground with a background-side halo, matching what the TV hero already did. Clear logos run through the channel-logo tone remap: light-toned marks recolor toward the theme foreground on light backdrops (network and downloaded artwork alike), colored marks keep their pixels, and dark themes render the original artwork. The backdrop-luminance gate is shared with the Live TV guide as logoToneTargetFor. --- lib/screens/discover_screen.dart | 7 +++++ lib/screens/livetv/guide_search_sheet.dart | 4 +-- lib/screens/livetv/livetv_styles.dart | 11 ------- .../livetv/reorder_favorites_sheet.dart | 7 ++--- lib/screens/livetv/tabs/guide_tab.dart | 3 +- lib/screens/media_detail_screen.dart | 29 +++++++++++++++---- lib/utils/tone_mapped_logo_image.dart | 12 ++++++++ lib/widgets/optimized_media_image.dart | 13 +++++++-- lib/widgets/tv_spotlight_background.dart | 20 +++++++++---- test/screens/media_detail_screen_test.dart | 18 +++++++++++- test/utils/tone_mapped_logo_image_test.dart | 15 ++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c2353fcec..820eae54d 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -55,6 +55,7 @@ import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import '../utils/layout_constants.dart'; import '../utils/platform_detector.dart'; +import '../utils/tone_mapped_logo_image.dart'; import '../theme/mono_tokens.dart'; import 'libraries/content_state_builder.dart'; import 'libraries/state_messages.dart'; @@ -1386,6 +1387,12 @@ class _DiscoverScreenState extends State width: heroLogoWidth, height: heroLogoHeight, alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter, + // The hero scrim washes artwork toward the scaffold + // background; light themes recolor light-toned logos. + logoToneTarget: logoToneTargetFor( + surface: theme.scaffoldBackgroundColor, + foreground: colorScheme.onSurface, + ), fallbackBuilder: (context) => FittingTitleText( showName, style: heroTitleStyle, diff --git a/lib/screens/livetv/guide_search_sheet.dart b/lib/screens/livetv/guide_search_sheet.dart index ef54f0904..3da1a68fc 100644 --- a/lib/screens/livetv/guide_search_sheet.dart +++ b/lib/screens/livetv/guide_search_sheet.dart @@ -16,6 +16,7 @@ import '../../services/companion_remote/companion_remote_receiver.dart'; import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; import '../../utils/live_tv_matching.dart'; +import '../../utils/tone_mapped_logo_image.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/bottom_sheet_header.dart'; import '../../widgets/focusable_list_tile.dart'; @@ -23,7 +24,6 @@ import '../../widgets/optimized_media_image.dart'; import '../../widgets/overlay_sheet.dart'; import '../../widgets/pill_input_decoration.dart'; import 'live_tv_server_iteration.dart'; -import 'livetv_styles.dart'; /// Search sheet for the Live TV guide: filters channels and the next 24 hours /// of programs in memory; selecting a result jumps to it in the guide grid. @@ -301,7 +301,7 @@ class _GuideSearchSheetState extends State with ControllerDisp width: 40, height: 40, fit: .contain, - logoToneTarget: channelLogoToneTarget( + logoToneTarget: logoToneTargetFor( surface: Theme.of(context).colorScheme.surface, foreground: Theme.of(context).colorScheme.onSurface, ), diff --git a/lib/screens/livetv/livetv_styles.dart b/lib/screens/livetv/livetv_styles.dart index 6da66bb92..72907f316 100644 --- a/lib/screens/livetv/livetv_styles.dart +++ b/lib/screens/livetv/livetv_styles.dart @@ -10,17 +10,6 @@ Color airingFill(BuildContext context) { return Color.alphaBlend(tk.text.withValues(alpha: 0.08), tk.surface); } -/// Remap target for a channel logo rendered on [surface]. -/// -/// Broadcast logos are white-on-transparent marks designed for dark UIs; on a -/// light backdrop they vanish (issue #2197), so light surfaces recolor -/// light-toned logos toward [foreground]. Dark backdrops — dark theme cards, -/// or the light theme's inverted focus card — render the original artwork. -/// Pass the result to `OptimizedMediaImage.logoToneTarget`. -Color? channelLogoToneTarget({required Color surface, required Color foreground}) { - return surface.computeLuminance() > 0.5 ? foreground : null; -} - /// Tinted M3E status pill (LIVE badge, recording / error state). class StatusPill extends StatelessWidget { final String label; diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index b2b9e9b7e..936ed7007 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -13,7 +13,7 @@ import '../../widgets/app_icon.dart'; import '../../widgets/bottom_sheet_header.dart'; import '../../widgets/overlay_sheet.dart'; import '../../widgets/optimized_media_image.dart'; -import 'livetv_styles.dart'; +import '../../utils/tone_mapped_logo_image.dart'; class ReorderFavoritesSheet extends StatefulWidget { final List favorites; @@ -191,10 +191,7 @@ class _ReorderFavoritesSheetState extends State width: 40, height: 40, fit: BoxFit.contain, - logoToneTarget: channelLogoToneTarget( - surface: colorScheme.surface, - foreground: colorScheme.onSurface, - ), + logoToneTarget: logoToneTargetFor(surface: colorScheme.surface, foreground: colorScheme.onSurface), ) : Center(child: AppIcon(Symbols.live_tv_rounded, fill: 1, color: colorScheme.onSurfaceVariant)), ), diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 047ed3ce9..0e3154328 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -30,6 +30,7 @@ import '../../../utils/formatters.dart'; import '../../../utils/live_tv_grouping.dart'; import '../../../utils/live_tv_matching.dart'; import '../../../utils/platform_detector.dart'; +import '../../../utils/tone_mapped_logo_image.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/app_menu.dart'; import '../../../widgets/clickable_cursor.dart'; @@ -1876,7 +1877,7 @@ class _ChannelCellState extends State<_ChannelCell> { width: widget.channelColumnWidth - 16, height: widget.rowHeight - 16, fit: BoxFit.contain, - logoToneTarget: channelLogoToneTarget( + logoToneTarget: logoToneTargetFor( surface: widget.isFocused ? theme.colorScheme.primary : tk.surface, foreground: widget.isFocused ? theme.colorScheme.onPrimary diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 1eedf29dc..0f7543cd1 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -87,6 +87,7 @@ import '../mixins/server_bound_media_mixin.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/deletion_notifier.dart'; import '../utils/global_key_utils.dart'; +import '../utils/tone_mapped_logo_image.dart'; import '../widgets/episode_card.dart'; import '../widgets/fitting_title_text.dart'; import 'actor_media_screen.dart'; @@ -877,6 +878,11 @@ class _MediaDetailScreenState extends State } /// Build title text widget for clear logo fallback. + /// + /// The hero scrim washes artwork toward the scaffold background, so the + /// default is the theme foreground with a background-side shadow — a + /// hard-coded white title disappears into the light theme's near-white + /// wash on bright covers. Widget _buildDetailTitle( BuildContext context, String title, { @@ -886,11 +892,12 @@ class _MediaDetailScreenState extends State Color? color, Color? shadowColor, }) { + final colorScheme = Theme.of(context).colorScheme; final baseStyle = (Theme.of(context).textTheme.displaySmall ?? const TextStyle()).copyWith( - color: color ?? Colors.white, + color: color ?? colorScheme.onSurface, fontWeight: fontWeight, fontSize: fontSize, - shadows: [Shadow(color: shadowColor ?? Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)], + shadows: [Shadow(color: shadowColor ?? _detailTitleShadowColor(context), blurRadius: shadowBlur)], ); return FittingTitleText(title, style: baseStyle); @@ -1227,6 +1234,7 @@ class _MediaDetailScreenState extends State required BoxFit fit, required ImageType imageType, Alignment alignment = Alignment.center, + Color? logoToneTarget, Widget Function(BuildContext, String, dynamic)? errorWidget, Widget Function(BuildContext, String)? placeholder, }) { @@ -1238,6 +1246,7 @@ class _MediaDetailScreenState extends State return OptimizedMediaImage( client: null, + logoToneTarget: logoToneTarget, imagePath: null, localFilePath: localPath, cacheMissingLocalFile: true, @@ -3616,8 +3625,6 @@ class _MediaDetailScreenState extends State fontSize: 56 * scale, fontWeight: .w800, shadowBlur: 12, - color: foregroundColor, - shadowColor: _tvDetailTitleShadowColor(context), ), ), SizedBox(height: logoMetadataGap), @@ -3782,7 +3789,10 @@ class _MediaDetailScreenState extends State Color _tvDetailForegroundColor(BuildContext context) => Theme.of(context).colorScheme.onSurface; - Color _tvDetailTitleShadowColor(BuildContext context) { + /// Background-side halo behind the hero title: dark themes shadow with + /// black, light themes with white, so the title separates from artwork the + /// scrim has not fully washed out. + Color _detailTitleShadowColor(BuildContext context) { final brightness = Theme.of(context).colorScheme.brightness; return brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.5) : Colors.white.withValues(alpha: 0.55); } @@ -3795,6 +3805,13 @@ class _MediaDetailScreenState extends State required Widget Function(BuildContext context, String title) titleBuilder, }) { Widget titleFallback(BuildContext context) => titleBuilder(context, metadata.displayTitle); + // The hero scrim washes the backdrop toward the scaffold background, so a + // light theme needs light-toned clear logos recolored to stay visible. + final theme = Theme.of(context); + final logoToneTarget = logoToneTargetFor( + surface: theme.scaffoldBackgroundColor, + foreground: theme.colorScheme.onSurface, + ); if (metadata.clearLogoPath == null) { return SizedBox(width: width, height: height, child: titleFallback(context)); @@ -3811,6 +3828,7 @@ class _MediaDetailScreenState extends State fit: BoxFit.contain, alignment: .centerLeft, imageType: ImageType.heroLogo, + logoToneTarget: logoToneTarget, placeholder: (context, url) => titleFallback(context), errorWidget: (context, url, error) => titleFallback(context), ); @@ -3821,6 +3839,7 @@ class _MediaDetailScreenState extends State logoPath: metadata.clearLogoPath, width: width, height: height, + logoToneTarget: logoToneTarget, fallbackBuilder: titleFallback, ); }, diff --git a/lib/utils/tone_mapped_logo_image.dart b/lib/utils/tone_mapped_logo_image.dart index f4b7319b2..b9e19dab0 100644 --- a/lib/utils/tone_mapped_logo_image.dart +++ b/lib/utils/tone_mapped_logo_image.dart @@ -7,6 +7,18 @@ import 'package:flutter/painting.dart'; import 'app_logger.dart'; import 'logo_tone.dart'; +/// Remap target for logo artwork rendered over [surface]. +/// +/// Channel logos and clear logos are usually white-on-transparent marks +/// designed for dark UIs; on a light backdrop they vanish (issue #2197), so +/// light backdrops recolor light-toned logos toward [foreground]. Dark +/// backdrops — dark theme cards and scrims, or the light theme's inverted +/// focus card — render the original artwork. Pass the result to +/// `OptimizedMediaImage.logoToneTarget` / `ClearLogoImage.logoToneTarget`. +Color? logoToneTargetFor({required Color surface, required Color foreground}) { + return surface.computeLuminance() > 0.5 ? foreground : null; +} + /// Cache key for [ToneMappedLogoImage]: the wrapped provider's key plus the /// remap target, so plain, light-adapted, and differently-targeted variants of /// the same artwork occupy distinct [ImageCache] entries. diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 78127bcc3..77e0a7904 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -10,6 +10,7 @@ import '../services/device_performance.dart'; import '../utils/app_logger.dart'; import '../utils/media_image_helper.dart'; import '../utils/obfuscation_utils.dart'; +import '../utils/tone_mapped_logo_image.dart'; /// Tracks recent image load failures to log a periodic summary instead of /// spamming per-image. Resets after [_logInterval] so recurring issues @@ -73,7 +74,7 @@ class OptimizedMediaImage extends StatelessWidget { /// Recolors light-toned logo artwork toward this theme foreground so it /// stays legible on light surfaces (see [ToneMappedLogoImage]). Applies to - /// the network decode path only; channel logos are online-only artwork. + /// both the network and local-file decode paths. final Color? logoToneTarget; const OptimizedMediaImage._({ @@ -246,11 +247,13 @@ class OptimizedMediaImage extends StatelessWidget { displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0, imageType: imageType, ); + final bounded = MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight); + final provider = logoToneTarget == null ? bounded : ToneMappedLogoImage(bounded, target: logoToneTarget!); return _withArtworkDim( artworkDim, (tint) => Image( - image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight), + image: provider, width: width, height: height, // Artwork is decorative: the enclosing card exposes one merged node @@ -460,6 +463,7 @@ class ClearLogoImage extends StatelessWidget { required this.fallbackBuilder, this.alignment = Alignment.centerLeft, this.fadeInDuration = const Duration(milliseconds: 300), + this.logoToneTarget, }); final MediaServerClient? client; @@ -470,6 +474,10 @@ class ClearLogoImage extends StatelessWidget { final Alignment alignment; final Duration fadeInDuration; + /// See [OptimizedMediaImage.logoToneTarget]; heroes pass a target when the + /// backdrop behind the logo is scrimmed toward a light background. + final Color? logoToneTarget; + @override Widget build(BuildContext context) { final path = logoPath; @@ -487,6 +495,7 @@ class ClearLogoImage extends StatelessWidget { alignment: alignment, imageType: ImageType.heroLogo, fadeInDuration: fadeInDuration, + logoToneTarget: logoToneTarget, placeholder: (context, _) => const SizedBox.shrink(), errorWidget: (context, _, _) => fallbackBuilder(context), ), diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 1c53bfd2d..7458c0863 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 '../services/settings_service.dart'; +import '../utils/tone_mapped_logo_image.dart'; import 'cycling_media_backdrop.dart'; import 'fitting_title_text.dart'; import 'fitted_metadata_line.dart'; @@ -224,6 +225,13 @@ class TvSpotlightBackground extends StatelessWidget { } Widget _buildLogoOrTitle(BuildContext context, MediaItem media, String title) { + final theme = Theme.of(context); + // The spotlight scrim washes artwork toward the scaffold background, so + // light themes recolor light-toned logos to stay visible. + final logoToneTarget = logoToneTargetFor( + surface: theme.scaffoldBackgroundColor, + foreground: theme.colorScheme.onSurface, + ); final scale = _scale(context); final logoPath = media.clearLogoPath; final logoWidth = _logoWidth(scale); @@ -240,16 +248,17 @@ class TvSpotlightBackground extends StatelessWidget { final localLogoPath = localArtworkPathResolver?.call(logoPath); if (localLogoPath != null && File(localLogoPath).existsSync()) { + final bounded = MediaImageHelper.boundedDecode( + FileImage(File(localLogoPath)), + memWidth: logoMemWidth, + memHeight: logoMemHeight, + ); return SizedBox( width: logoWidth, height: logoHeight, child: blurArtwork( Image( - image: MediaImageHelper.boundedDecode( - FileImage(File(localLogoPath)), - memWidth: logoMemWidth, - memHeight: logoMemHeight, - ), + image: logoToneTarget == null ? bounded : ToneMappedLogoImage(bounded, target: logoToneTarget), fit: BoxFit.contain, alignment: .centerLeft, errorBuilder: (context, error, stackTrace) => _buildTitle(context, title), @@ -266,6 +275,7 @@ class TvSpotlightBackground extends StatelessWidget { width: logoWidth, height: logoHeight, fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)), + logoToneTarget: logoToneTarget, fallbackBuilder: (context) => _buildTitle(context, title), ); } diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index a3ab84cc5..8cae084b5 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -42,6 +42,7 @@ import 'package:plezy/utils/video_player_navigation.dart'; import 'package:plezy/widgets/collapsible_text.dart'; import 'package:plezy/widgets/cycling_media_backdrop.dart'; import 'package:plezy/widgets/episode_card.dart'; +import 'package:plezy/widgets/fitting_title_text.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:plezy/widgets/media_details_sheet.dart'; import 'package:provider/provider.dart'; @@ -1141,6 +1142,7 @@ void main() { int? initialSeasonIndex, String? initialEpisodeId, NavigatorObserver? observer, + ThemeData? theme, }) async { TvDetectionService.debugSetAppleTVOverride(false); await SettingsService.getInstance(); @@ -1188,7 +1190,7 @@ void main() { ], child: MaterialApp( navigatorObservers: [?observer], - theme: monoTheme(dark: true), + theme: theme ?? monoTheme(dark: true), home: withProfileNavigationScope( child: MediaDetailScreen( metadata: show, @@ -1240,6 +1242,20 @@ void main() { ); } + testWidgets('phone hero title fallback uses the light theme foreground', (tester) async { + // The hero scrim washes artwork toward the near-white light background; + // the old hard-coded white title vanished into it on bright covers. + final show = buildShow(); + final theme = monoTheme(dark: false); + await pumpPhoneDetail(tester, singleSeasonClient(show), show, theme: theme); + + final heroTitle = tester.widget(find.byType(FittingTitleText).first); + expect(heroTitle.style?.color, theme.colorScheme.onSurface); + final shadow = heroTitle.style?.shadows?.single; + expect(shadow, isNotNull); + expect(shadow!.color.computeLuminance(), greaterThan(0.5), reason: 'light theme halos with a light shadow'); + }); + testWidgets('paints the item before the on-deck lookup settles', (tester) async { // Jellyfin needs a second round trip for on-deck; the phone/desktop // layout must not wait for it. Scoped to non-TV deliberately: on TV the diff --git a/test/utils/tone_mapped_logo_image_test.dart b/test/utils/tone_mapped_logo_image_test.dart index 7a72cf13f..e85193795 100644 --- a/test/utils/tone_mapped_logo_image_test.dart +++ b/test/utils/tone_mapped_logo_image_test.dart @@ -111,4 +111,19 @@ void main() { plain.dispose(); expect(PaintingBinding.instance.imageCache.currentSize, 2); }); + + test('logoToneTargetFor engages only over light backdrops', () { + const foreground = Color(0xFF111111); + // Light theme surfaces and backgrounds. + expect(logoToneTargetFor(surface: const Color(0xFFFFFFFF), foreground: foreground), foreground); + expect(logoToneTargetFor(surface: const Color(0xFFF7F7F8), foreground: foreground), foreground); + // Dark theme surfaces and the light theme's inverted (dark) focus card. + expect(logoToneTargetFor(surface: const Color(0xFF15171C), foreground: foreground), isNull); + expect(logoToneTargetFor(surface: const Color(0xFF111111), foreground: foreground), isNull); + // The dark theme's inverted focus card is light and re-engages the remap. + expect( + logoToneTargetFor(surface: const Color(0xFFEDEDED), foreground: const Color(0xFF0E0F12)), + const Color(0xFF0E0F12), + ); + }); }