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.
This commit is contained in:
edde746
2026-08-31 18:51:46 +02:00
parent 274b31ebac
commit 976bdbd021
11 changed files with 107 additions and 32 deletions
+7
View File
@@ -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<DiscoverScreen>
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,
+2 -2
View File
@@ -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<GuideSearchSheet> with ControllerDisp
width: 40,
height: 40,
fit: .contain,
logoToneTarget: channelLogoToneTarget(
logoToneTarget: logoToneTargetFor(
surface: Theme.of(context).colorScheme.surface,
foreground: Theme.of(context).colorScheme.onSurface,
),
-11
View File
@@ -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;
@@ -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<FavoriteChannel> favorites;
@@ -191,10 +191,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet>
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)),
),
+2 -1
View File
@@ -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
+24 -5
View File
@@ -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<MediaDetailScreen>
}
/// 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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
return OptimizedMediaImage(
client: null,
logoToneTarget: logoToneTarget,
imagePath: null,
localFilePath: localPath,
cacheMissingLocalFile: true,
@@ -3616,8 +3625,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
fontSize: 56 * scale,
fontWeight: .w800,
shadowBlur: 12,
color: foregroundColor,
shadowColor: _tvDetailTitleShadowColor(context),
),
),
SizedBox(height: logoMetadataGap),
@@ -3782,7 +3789,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
logoPath: metadata.clearLogoPath,
width: width,
height: height,
logoToneTarget: logoToneTarget,
fallbackBuilder: titleFallback,
);
},
+12
View File
@@ -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.
+11 -2
View File
@@ -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),
),
+15 -5
View File
@@ -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),
);
}
+17 -1
View File
@@ -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<FittingTitleText>(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
@@ -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),
);
});
}