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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user