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