feat(tv): add corner spotlight backdrop option (#1628)

New TV-only Appearance toggle that pins the spotlight artwork to the
top-right corner (68% width, 72% height) with the left and bottom edges
feathered into the scaffold background, so the logo, metadata, and
shelves sit on a calm surface instead of the image. Default is off —
the full-bleed spotlight stays exactly as it is.

The artwork keeps its full-screen request size: the corner box only
crops the layout, so transcode URLs and image caches are unchanged and
toggling the setting stays instant.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Albert
2026-07-20 10:06:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0707d4d9b1
commit f8cb550be7
9 changed files with 140 additions and 73 deletions
+2
View File
@@ -108,6 +108,8 @@
"libraryDensity": "Mediathekdichte",
"compact": "Kompakt",
"comfortable": "Großzügig",
"tvCornerSpotlightBackdrop": "Backdrop in der Ecke",
"tvCornerSpotlightBackdropDescription": "Zeigt das Spotlight-Artwork oben rechts statt bildschirmfüllend",
"viewMode": "Ansichtsmodus",
"gridView": "Raster",
"listView": "Liste",
+2
View File
@@ -108,6 +108,8 @@
"libraryDensity": "Library Density",
"compact": "Compact",
"comfortable": "Comfortable",
"tvCornerSpotlightBackdrop": "Corner Spotlight Backdrop",
"tvCornerSpotlightBackdropDescription": "Show spotlight artwork in the top-right corner instead of filling the screen",
"viewMode": "View Mode",
"gridView": "Grid",
"listView": "List",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 22851 (1428 per locale)
/// Strings: 22855 (1428 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+8 -4
View File
@@ -239,6 +239,8 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn {
@override String get libraryDensity => 'Mediathekdichte';
@override String get compact => 'Kompakt';
@override String get comfortable => 'Großzügig';
@override String get tvCornerSpotlightBackdrop => 'Backdrop in der Ecke';
@override String get tvCornerSpotlightBackdropDescription => 'Zeigt das Spotlight-Artwork oben rechts statt bildschirmfüllend';
@override String get viewMode => 'Ansichtsmodus';
@override String get gridView => 'Raster';
@override String get listView => 'Liste';
@@ -2221,6 +2223,8 @@ extension on TranslationsDe {
'settings.libraryDensity' => 'Mediathekdichte',
'settings.compact' => 'Kompakt',
'settings.comfortable' => 'Großzügig',
'settings.tvCornerSpotlightBackdrop' => 'Backdrop in der Ecke',
'settings.tvCornerSpotlightBackdropDescription' => 'Zeigt das Spotlight-Artwork oben rechts statt bildschirmfüllend',
'settings.viewMode' => 'Ansichtsmodus',
'settings.gridView' => 'Raster',
'settings.listView' => 'Liste',
@@ -2633,10 +2637,10 @@ extension on TranslationsDe {
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, one: 'Automatisch entfernt: ${n} angesehener Download', other: 'Automatisch entfernt: ${n} angesehene Downloads', ),
'messages.removedFromContinueWatching' => 'Aus Weiterschauen\' entfernt',
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
_ => null,
} ?? switch (path) {
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}',
'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie',
@@ -3147,10 +3151,10 @@ extension on TranslationsDe {
'watchTogether.guestSwitchUnavailable' => 'Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar',
'watchTogether.guestSwitchFailed' => 'Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Verwalten',
'downloads.tvShows' => 'Serien',
_ => null,
} ?? switch (path) {
'downloads.manage' => 'Verwalten',
'downloads.tvShows' => 'Serien',
'downloads.movies' => 'Filme',
'downloads.music' => 'Musik',
'downloads.tracksQueued' => ({required Object count}) => '${count} Titel zum Download in Warteschlange',
+12 -4
View File
@@ -437,6 +437,12 @@ class TranslationsSettingsEn {
/// en: 'Comfortable'
String get comfortable => 'Comfortable';
/// en: 'Corner Spotlight Backdrop'
String get tvCornerSpotlightBackdrop => 'Corner Spotlight Backdrop';
/// en: 'Show spotlight artwork in the top-right corner instead of filling the screen'
String get tvCornerSpotlightBackdropDescription => 'Show spotlight artwork in the top-right corner instead of filling the screen';
/// en: 'View Mode'
String get viewMode => 'View Mode';
@@ -5078,6 +5084,8 @@ extension on Translations {
'settings.libraryDensity' => 'Library Density',
'settings.compact' => 'Compact',
'settings.comfortable' => 'Comfortable',
'settings.tvCornerSpotlightBackdrop' => 'Corner Spotlight Backdrop',
'settings.tvCornerSpotlightBackdropDescription' => 'Show spotlight artwork in the top-right corner instead of filling the screen',
'settings.viewMode' => 'View Mode',
'settings.gridView' => 'Grid',
'settings.listView' => 'List',
@@ -5490,10 +5498,10 @@ extension on Translations {
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ),
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
_ => null,
} ?? switch (path) {
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
'messages.fileInfoNotAvailable' => 'File information not available',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
@@ -6004,10 +6012,10 @@ extension on Translations {
'watchTogether.guestSwitchUnavailable' => 'Couldn\'t switch — server unavailable for sync',
'watchTogether.guestSwitchFailed' => 'Couldn\'t switch — content not found on this server',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows',
_ => null,
} ?? switch (path) {
'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows',
'downloads.movies' => 'Movies',
'downloads.music' => 'Music',
'downloads.tracksQueued' => ({required Object count}) => '${count} tracks queued for download',
@@ -49,6 +49,13 @@ class AppearanceSettingsScreen extends StatelessWidget {
title: t.settings.tvFullCardLayout,
subtitle: t.settings.tvFullCardLayoutDescription,
),
if (PlatformDetector.isTV())
SettingSwitchTile(
pref: SettingsService.tvCornerSpotlightBackdrop,
icon: Symbols.picture_in_picture_alt_rounded,
title: t.settings.tvCornerSpotlightBackdrop,
subtitle: t.settings.tvCornerSpotlightBackdropDescription,
),
if (PlatformDetector.isTV())
SettingSwitchTile(
pref: SettingsService.focusGlow,
+2
View File
@@ -468,6 +468,7 @@ class SettingsService extends BaseSharedPreferencesService {
static const bufferSize = _BufferSizePref();
static const libraryDensity = _LibraryDensityPref();
static const tvCornerSpotlightBackdrop = BoolPref('tv_corner_spotlight_backdrop');
static const episodePosterMode = _EpisodePosterModePref();
static const continueWatchingAction = EnumPref<ContinueWatchingAction>(
'continue_watching_action',
@@ -859,6 +860,7 @@ class SettingsService extends BaseSharedPreferencesService {
themeMode,
keyboardHotkeys,
libraryDensity,
tvCornerSpotlightBackdrop,
episodePosterMode,
mediaVersionPreferences,
localLastPlayedAt,
+99 -63
View File
@@ -15,9 +15,11 @@ import '../utils/content_utils.dart';
import '../utils/formatters.dart';
import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart';
import '../services/settings_service.dart';
import 'app_icon.dart';
import 'cycling_media_backdrop.dart';
import 'fitting_title_text.dart';
import 'settings_builder.dart';
import 'media_rating_badge.dart';
import 'optimized_media_image.dart' show blurArtwork;
import 'rasterized_gradient.dart';
@@ -69,73 +71,107 @@ class TvSpotlightBackground extends StatelessWidget {
final fallbackPaths = media == null
? const <String>[]
: <String>[...media.heroArtCandidates(containerAspectRatio: containerAspect), ?media.thumbPath];
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: blurArtwork(
CyclingMediaBackdrop(
mediaKey: media?.globalKey,
imagePaths: media?.heroBackdropPaths ?? const [],
fallbackImagePaths: fallbackPaths,
client: client,
localArtworkPathResolver: localArtworkPathResolver == null
? null
: (path) => localArtworkPathResolver!(path),
allowNetwork: allowNetwork,
width: size.width,
height: size.height,
fallbackColor: media == null ? bgColor : Theme.of(context).colorScheme.surfaceContainerHighest,
return SettingValueBuilder<bool>(
pref: SettingsService.tvCornerSpotlightBackdrop,
builder: (context, cornerBackdrop, _) {
final backdropSize = cornerBackdrop ? Size(size.width * 0.68, size.height * 0.72) : size;
final backdrop = CyclingMediaBackdrop(
mediaKey: media?.globalKey,
imagePaths: media?.heroBackdropPaths ?? const [],
fallbackImagePaths: fallbackPaths,
client: client,
localArtworkPathResolver: localArtworkPathResolver == null ? null : (path) => localArtworkPathResolver!(path),
allowNetwork: allowNetwork,
// Always request at full-screen size: the corner box only crops the
// layout. A mode-dependent size would change the transcode URL and
// cold-start every cached backdrop when the setting is toggled.
width: size.width,
height: size.height,
fallbackColor: media == null ? bgColor : Theme.of(context).colorScheme.surfaceContainerHighest,
);
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: cornerBackdrop ? _buildCornerBackdrop(backdropSize, backdrop) : blurArtwork(backdrop),
),
),
),
_buildHorizontalScrim(bgColor),
RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)],
stops: const [0.0, 0.38, 1.0],
),
),
if (media != null && showInfo)
Positioned(
left: contentLeft ?? TvLayoutConstants.horizontalInset,
right: MediaQuery.sizeOf(context).width * 0.43,
top: contentTop,
bottom: contentBottom,
// The info block still cross-fades via AnimatedSwitcher, but its
// saveLayers are bounded to the text region, not the screen.
child: AnimatedSwitcher(
duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
// Expand instead of the default loose centered Stack so the
// info keeps filling the region and bottom-left aligning.
layoutBuilder: (currentChild, previousChildren) =>
Stack(fit: StackFit.expand, children: [...previousChildren, ?currentChild]),
child: KeyedSubtree(
key: ValueKey(media.globalKey),
child: LayoutBuilder(
builder: (context, constraints) {
if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) {
return Align(alignment: .bottomLeft, child: _buildInfo(context, media));
}
return Align(
alignment: .bottomLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: .bottomLeft,
child: SizedBox(width: constraints.maxWidth, child: _buildInfo(context, media)),
),
);
},
),
_buildHorizontalScrim(bgColor),
RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)],
stops: const [0.0, 0.38, 1.0],
),
),
if (media != null && showInfo)
Positioned(
left: contentLeft ?? TvLayoutConstants.horizontalInset,
right: MediaQuery.sizeOf(context).width * 0.43,
top: contentTop,
bottom: contentBottom,
// The info block still cross-fades via AnimatedSwitcher, but its
// saveLayers are bounded to the text region, not the screen.
child: AnimatedSwitcher(
duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
// Expand instead of the default loose centered Stack so the
// info keeps filling the region and bottom-left aligning.
layoutBuilder: (currentChild, previousChildren) =>
Stack(fit: StackFit.expand, children: [...previousChildren, ?currentChild]),
child: KeyedSubtree(
key: ValueKey(media.globalKey),
child: LayoutBuilder(
builder: (context, constraints) {
if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) {
return Align(alignment: .bottomLeft, child: _buildInfo(context, media));
}
return Align(
alignment: .bottomLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: .bottomLeft,
child: SizedBox(width: constraints.maxWidth, child: _buildInfo(context, media)),
),
);
},
),
),
),
),
],
);
},
);
}
/// Corner spotlight: artwork pinned to the top-right corner, left and
/// bottom edges feathered into the scaffold background so the info block
/// sits on a calm surface instead of the image.
Widget _buildCornerBackdrop(Size backdropSize, Widget backdrop) {
return Align(
alignment: Alignment.topRight,
child: SizedBox(
width: backdropSize.width,
height: backdropSize.height,
child: ShaderMask(
shaderCallback: (rect) =>
const LinearGradient(colors: [Colors.transparent, Colors.white], stops: [0.0, 0.35]).createShader(rect),
blendMode: BlendMode.dstIn,
child: ShaderMask(
shaderCallback: (rect) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.55, 1.0],
).createShader(rect),
blendMode: BlendMode.dstIn,
child: backdrop,
),
],
),
),
);
}
@@ -5,9 +5,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/widgets/cycling_media_backdrop.dart';
import 'package:plezy/widgets/tv_spotlight_background.dart';
import '../test_helpers/prefs.dart';
const _png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const _rotationInterval = Duration(seconds: 1);
const _fadeDuration = Duration(milliseconds: 20);
@@ -19,7 +22,10 @@ void main() {
late File third;
late Map<String, MemoryImage> imageProviders;
setUp(() {
setUp(() async {
// TvSpotlightBackground reads the corner-backdrop pref during build.
resetSharedPreferencesForTest();
await SettingsService.getInstance();
directory = Directory.systemTemp.createTempSync('plezy-backdrop-cycle');
final bytes = base64Decode(_png);
first = File('${directory.path}/first.png')..writeAsBytesSync(bytes);