diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index f84592b5..bb35046b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 6eaa184e..2745ac1b 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 8441ee96..8ce829a4 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index e1ff1034..07db9aba 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 525a2e9b..c4054df4 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 3931093c..17f85e16 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -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, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index c48a7a9f..3c9dfefb 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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( 'continue_watching_action', @@ -859,6 +860,7 @@ class SettingsService extends BaseSharedPreferencesService { themeMode, keyboardHotkeys, libraryDensity, + tvCornerSpotlightBackdrop, episodePosterMode, mediaVersionPreferences, localLastPlayedAt, diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 5721edc5..6caba3dc 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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 [] : [...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( + 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, ), - ], + ), + ), ); } diff --git a/test/widgets/cycling_media_backdrop_test.dart b/test/widgets/cycling_media_backdrop_test.dart index 76147f73..1f9f5056 100644 --- a/test/widgets/cycling_media_backdrop_test.dart +++ b/test/widgets/cycling_media_backdrop_test.dart @@ -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 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);